Coverage for src/local_deep_research/web/dependencies/auth.py: 97%
145 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2FastAPI authentication dependencies.
4Replaces Flask's @login_required decorator and g.db_session / g.current_user
5with explicit dependency injection.
6"""
8from contextlib import contextmanager
9from contextvars import ContextVar
10from typing import Annotated, Generator
12from fastapi import Depends, HTTPException, Request
13from loguru import logger
14from sqlalchemy.orm import Session
16from ...database.encrypted_db import (
17 UserConnectionStatus,
18 db_manager,
19 sidecar_wait_budget,
20)
21from ...database.rekey_lock import RekeyOperationalError
22from ...database.session_context import get_user_db_session
23from ...database.session_passwords import session_password_store
24from ...utilities.db_utils import get_settings_manager
25from ..auth.session_manager import session_manager
28def get_session_username(request: Request) -> str | None:
29 """Get the username from the session, or None if not authenticated."""
30 return request.session.get("username")
33# Type names of the rekey operational faults that DatabaseMiddleware's reopen
34# of the user's database met in the current request (ensure_user_database);
35# None outside a request_database_scope. A list, so the reopen that runs on a
36# worker thread (with a copy of the request's context) can append to the one
37# require_auth reads later on another.
38#
39# Request-scoped state: contexts copied from the request (research, RAG and
40# post-login threads) keep this list after the request ends, so it must only
41# be read by require_auth (through _database_connection_status), in the
42# request that wrote it, never from background work.
43_reopen_faults: ContextVar[list[str] | None] = ContextVar(
44 "ldr_request_reopen_faults", default=None
45)
48@contextmanager
49def request_database_scope() -> Generator[None, None, None]:
50 """Scope one HTTP request's database bookkeeping (``DatabaseMiddleware``).
52 * The request's waits for the user's rekey sidecar share a give-up flag
53 (``sidecar_wait_budget``): DatabaseMiddleware's probe and reopen,
54 ``require_auth``'s probes and the route's own database opens. A wait
55 that gives up sets it, and each later claim then tries once without
56 waiting, until one of them gets the sidecar again. The flag is not a
57 running total of the time waited.
58 * A rekey operational fault that the reopen in ``ensure_user_database``
59 met is kept for ``require_auth``, which answers the missing engine
60 with the retryable 503 instead of the 401 that ``/`` turns into a
61 logout.
62 """
63 token = _reopen_faults.set([])
64 try:
65 with sidecar_wait_budget():
66 yield
67 finally:
68 _reopen_faults.reset(token)
71def _record_reopen_fault(fault: RekeyOperationalError) -> None:
72 faults = _reopen_faults.get()
73 if faults is not None: 73 ↛ exitline 73 didn't return from function '_record_reopen_fault' because the condition on line 73 was always true
74 faults.append(type(fault).__name__)
77def clear_session_if_unrecoverable(request: Request, username: str) -> bool:
78 """Drop a stale session that has no database recovery credential.
80 Ports ``cleanup_stale_sessions()``, which Flask ran as a before_request
81 handler on every request (``web/auth/session_cleanup.py`` on main, deleted
82 by this migration with no successor). Without it, a session whose password
83 has been evicted keeps a valid ``username`` cookie that 401s on every
84 protected route: the browser still believes it is logged in and nothing
85 tells it otherwise. An API or XHR client polling endpoints would never
86 recover.
88 Only clears when recovery is genuinely impossible, matching the original:
89 a temp auth token or a stored session password means
90 ``ensure_user_database()`` can still reopen the connection, so the session
91 is stale rather than dead and must be left alone.
93 This also runs when a server-side session is still valid and its shared
94 connection is open. Background work or another device can keep that
95 connection alive after this session loses its required credential.
97 When it clears the cookie it also destroys the matching server-side
98 session and disconnects that session's sockets
99 (``_revoke_server_session``), because the cleared browser can no longer
100 reach logout to do it.
102 Returns True if the session was cleared.
103 """
104 if request.session.get("temp_auth_token"):
105 # Post-login bootstrap credential — ensure_user_database() consumes it
106 # and opens the database.
107 return False
109 if not db_manager.has_encryption:
110 # Unencrypted databases open with the dummy password; a missing
111 # connection here is not a credential problem.
112 return False
114 session_id = request.session.get("session_id")
115 if session_id and session_password_store.get_session_password(
116 username, session_id
117 ):
118 # The store can still reopen it.
119 return False
121 logger.info(
122 "Clearing stale session for {} — no recovery credential", username
123 )
124 if session_id:
125 _revoke_server_session(session_id, username)
126 request.session.clear()
127 return True
130def _revoke_server_session(session_id: str, username: str) -> None:
131 """Destroy the server-side session behind a cookie that is being cleared.
133 Clearing only the cookie would leave the ``session_manager`` record alive
134 for the rest of its idle timeout (up to ``session_remember_me_days``).
135 Once the cookie is gone the browser cannot reach logout, the path that
136 normally destroys it. A replayed copy of the cookie would keep renewing
137 that record, and checks that rely on ``validate_session`` alone (the
138 Socket.IO connect gate, ``has_active_sessions_for`` in the idle sweeper)
139 would keep treating the session as live. So destroy the record and
140 disconnect its sockets, the same per-session teardown logout performs.
142 Only this session is affected. The user's other sessions keep working.
143 A session id owned by a different user is left alone. Teardown is
144 best-effort so a failure here cannot turn the 401 into a 500.
145 """
146 try:
147 owner = session_manager.validate_session(session_id)
148 if owner is not None and owner != username:
149 return
150 if owner is not None: 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true
151 session_manager.destroy_session(session_id)
152 from ..services.socketio_asgi import disconnect_session
154 disconnect_session(session_id)
155 except Exception as exc:
156 # Type name only: a rendered traceback would include session_id.
157 failure = type(exc).__name__
158 else:
159 return
160 logger.warning(
161 "Failed to revoke an unrecoverable server-side session: {}", failure
162 )
165def end_rejected_session(request: Request) -> None:
166 """Log out a browser whose session ``require_auth`` rejected but kept.
168 ``require_auth`` keeps the cookie on one 401 path on purpose: the user's
169 database is not connected, but ``clear_session_if_unrecoverable`` still
170 sees a way to recover (this session's stored password, a temp auth token,
171 or an unencrypted install). If the reopen then keeps failing (missing or
172 corrupt database file, a failed migration, the close/rekey window of a
173 password change), every request 401s with ``username`` still in the
174 cookie. ``/auth/login`` sends any cookie with a username back to ``/``,
175 so a page that redirects that 401 to the login page without clearing the
176 cookie loops between the two until the browser gives up. A cached engine
177 that merely cannot be checked right now (a rekey sidecar fault), or a
178 reopen that failed with a rekey operational fault (another holder
179 keeping the sidecar past the wait bound, for one), is not such a
180 failure: ``require_auth`` answers it with a 503, which never reaches
181 this function.
183 Call this before redirecting such a 401 to the login page. It destroys
184 the server-side session, disconnects its sockets, drops this session's
185 stored password, and clears the cookie, so the browser lands on the
186 login form. Other sessions of the same user are not touched. Does
187 nothing when ``require_auth`` already cleared the session.
188 """
189 username = request.session.get("username")
190 if not username:
191 return
192 session_id = request.session.get("session_id")
193 if session_id: 193 ↛ 203line 193 didn't jump to line 203 because the condition on line 193 was always true
194 _revoke_server_session(session_id, username)
195 try:
196 session_password_store.clear_session(username, session_id)
197 except Exception as exc:
198 # Type name only: a rendered traceback would include session_id.
199 logger.warning(
200 "Failed to drop a rejected session's stored password: {}",
201 type(exc).__name__,
202 )
203 request.session.clear()
206# Seconds a client is asked to wait before retrying a request answered with
207# ``database_unavailable_exception``'s 503: the user's database could not be
208# checked or opened because of a rekey operational fault (a transient
209# sidecar fault such as an fd-exhaustion spike).
210DATABASE_UNAVAILABLE_RETRY_AFTER_SECONDS = 5
213def database_unavailable_exception() -> HTTPException:
214 """The retryable 503 for a rekey operational fault after authentication.
216 Every moment that meets such a fault answers the same way:
218 * ``require_auth``'s own probe cannot check the cached engine, or no
219 engine is cached because this request's reopen of the database met
220 such a fault (``UserConnectionStatus.UNAVAILABLE``);
221 * the probe succeeded, but opening the request's database session a
222 moment later failed on the same kind of fault
223 (``DatabaseSessionUnavailableError`` from ``get_user_db_session``),
224 which an fd-exhaustion spike that comes and goes produces: ``/`` and
225 ``get_db_session_dep`` raise this for it (``/`` answers a browser,
226 for this and for ``require_auth``'s own 503, with the branded error
227 page instead: same status and ``Retry-After``, and a button that
228 retries ``/``);
229 * a route left that error, or a ``RekeyOperationalError`` raised by a
230 direct ``db_manager`` call, unhandled: the app's exception handler
231 for both (``fastapi_app._register_exception_handlers``) answers this.
232 That includes a settings read through ``get_settings_manager``
233 without a session (``db_manager.get_session`` raises the fault rather
234 than letting it read as "no database", i.e. every setting at its
235 default); ``POST /metrics/api/journal-data/download``, whose own
236 catch-all would answer 500, passes it on to that handler.
238 None is a verdict on the session, so whoever raises this leaves the
239 cookie, the server-side session and the stored password alone. A fresh
240 instance each call: ``HTTPException`` carries mutable headers.
241 """
242 return HTTPException(
243 status_code=503,
244 detail="Your database is temporarily unavailable. "
245 "Please try again in a moment.",
246 headers={"Retry-After": str(DATABASE_UNAVAILABLE_RETRY_AFTER_SECONDS)},
247 )
250def _database_connection_status(username: str) -> UserConnectionStatus:
251 """Whether ``username``'s database can serve this request right now.
253 ``is_user_connected`` stays the single probe on the common path. Its
254 False is ambiguous, though: either no usable engine is cached (none, or
255 a stale one the probe just detached), or a cached engine could not be
256 checked because the rekey sidecar could not be opened or locked, or the
257 epoch read failed (an fd-exhaustion spike, EACCES, EIO). The manager
258 keeps the engine in the second case and re-checks it on the next
259 probe. So a False is followed by ``probe_user_connection``, which tells
260 the two apart; its answer is the more recent one, so it decides.
262 No cached engine is not a credential verdict either when this
263 request's reopen of the database (``ensure_user_database``) failed with
264 a rekey operational fault: its cold open could not take the sidecar
265 (another holder kept it past the wait bound, an fd-exhaustion spike),
266 could not read or publish the epoch, or found durable rekey state that
267 needs recovery. None of these says anything against the password, so
268 that is ``UNAVAILABLE`` too.
269 """
270 try:
271 if db_manager.is_user_connected(username):
272 return UserConnectionStatus.CONNECTED
273 except RekeyOperationalError:
274 # The probe takes the rekey sidecar (filesystem + a lock it waits
275 # for at most a few seconds). Opening, locking (a holder that keeps
276 # it past that bound included) and reading are reported as a plain
277 # False, but a failure to release the sidecar afterwards
278 # (unlock/close) raises RekeySidecarIOError, which is not an
279 # OSError. It is the same transient sidecar I/O fault, not a verdict
280 # on the session.
281 return UserConnectionStatus.UNAVAILABLE
282 connection = db_manager.probe_user_connection(username)
283 if (
284 connection is UserConnectionStatus.NOT_CONNECTED
285 and _reopen_faults.get()
286 ):
287 return UserConnectionStatus.UNAVAILABLE
288 return connection
291def require_auth(request: Request) -> str:
292 """Require authentication. Returns username or raises 401 (or 503).
294 Replaces Flask's @login_required decorator.
295 For API routes, returns JSON 401. For HTML routes, could redirect
296 (but callers handle that distinction).
298 A session that would otherwise be accepted gets a 503 with
299 ``Retry-After`` instead while its cached database engine cannot be
300 checked because of a rekey sidecar fault, or while no engine is cached
301 because this request's reopen failed with a rekey operational fault
302 (another holder keeping the sidecar past the wait bound, for one). The
303 session, its
304 server-side record and its stored password are all kept: a 401 there
305 would be turned into a logout by ``/`` (``end_rejected_session``)
306 after a transient fault such as an fd-exhaustion spike. A fault that
307 only shows when the route then opens its database session surfaces as
308 ``DatabaseSessionUnavailableError`` and gets the same 503
309 (``database_unavailable_exception``).
310 """
311 username = request.session.get("username")
312 if not username:
313 raise HTTPException(status_code=401, detail="Authentication required")
315 connection = _database_connection_status(username)
316 if connection is not UserConnectionStatus.CONNECTED and (
317 connection is not UserConnectionStatus.UNAVAILABLE
318 ):
319 # Stale session with no recoverable credential: clear the cookie (and
320 # revoke the server-side session) so the client is sent back to login
321 # instead of 401-ing on every request until someone loads "/".
322 clear_session_if_unrecoverable(request, username)
323 raise HTTPException(
324 status_code=401, detail="Database connection required"
325 )
327 # Validate the SERVER-SIDE session, not just the username claim inside the
328 # signed cookie.
329 #
330 # Without this, revocation does not work. The checks below it are both
331 # username-scoped: `is_user_connected` is true whenever ANY session for that
332 # user has the database open, and the password resolver
333 # (`get_user_db_session` -> `get_any_session_password`) hands back whichever
334 # session's password is live. So a cookie captured before logout is rejected
335 # only while the user stays logged out, and is accepted again the moment
336 # they — or anyone on any device — logs in again. Demonstrated: log in,
337 # capture cookie, log out (401 as expected), log in again, replay the
338 # original cookie -> 200 with real data.
339 #
340 # Flask did not need this: `get_user_db_session` resolved the password from
341 # `flask_session["session_id"]`, so a replayed cookie whose session had been
342 # destroyed found no password and failed at the database layer.
343 # `get_any_session_password` (added on this branch, absent from main) removed
344 # that incidental protection, because 154 router call sites invoke
345 # `get_user_db_session(username)` without threading a session_id through.
346 #
347 # Validating the session id here restores revocation for every route at one
348 # chokepoint. It also gives `session_timeout_hours` / `remember_me_days`
349 # real effect, since `validate_session` enforces the timeouts and refreshes
350 # last-access on use.
351 if not _server_session_valid(request, username):
352 # Destroyed by logout or password change, expired, or never valid.
353 # Clear the cookie so the client stops presenting a dead session.
354 request.session.clear()
355 raise HTTPException(status_code=401, detail="Authentication required")
357 if connection is UserConnectionStatus.UNAVAILABLE:
358 # The session is valid, but its database cannot be checked: the
359 # user's engine is still cached and its rekey epoch could not be
360 # checked (the sidecar could not be opened, locked or released, or
361 # the epoch read failed), or no engine is cached and the reopen
362 # DatabaseMiddleware attempted failed with a rekey operational
363 # fault (_database_connection_status). Answer a
364 # retryable 503 and touch nothing: a 401 here reaches "/", whose
365 # end_rejected_session destroys the session and its stored
366 # password, so an fd-exhaustion spike or a held sidecar would log
367 # the user out. With an engine cached, that reopen failed fast too:
368 # open_user_database raises the same fault before deriving a key.
369 raise database_unavailable_exception()
371 return username
374def _server_session_valid(request: Request, username: str) -> bool:
375 """Whether the login still owns a usable authenticated session.
377 An open shared connection alone cannot make an encrypted database usable:
378 request handlers still need the session's in-memory credential. Require
379 re-authentication when it is gone, even if background work keeps the
380 connection open.
382 Split out of ``require_auth`` as a named seam rather than inlined, so a
383 test suite can relax the server-side-session gate without relaxing
384 authentication itself. Many route tests authenticate with the legacy
385 idiom — a bare ``username`` in the session plus a mocked ``db_manager``,
386 never creating a server-side session — which this gate correctly
387 rejects. ``tests/conftest.py``'s autouse ``_legacy_bare_username_auth``
388 patches this one function so those tests keep working, while tests that
389 must prove a destroyed session IS rejected opt out with
390 ``@pytest.mark.real_session_check`` and exercise the real check.
392 Only ever called after a username has been confirmed present, so
393 "accept unconditionally" is exactly the pre-revocation contract.
394 """
395 session_id = request.session.get("session_id")
396 valid_owner = bool(
397 session_id and session_manager.validate_session(session_id) == username
398 )
399 return valid_owner and not clear_session_if_unrecoverable(request, username)
402def get_db_session_dep(
403 request: Request,
404 username: Annotated[str, Depends(require_auth)],
405) -> Generator[Session, None, None]:
406 """Yield a database session for the authenticated user.
408 Passes the current request's session_id through to
409 get_user_db_session so the password lookup is bound to this
410 request's session rather than falling back to "any active
411 session's password" (cross-session leak risk).
413 NOT WIRED TO ANY ROUTE, AND DO NOT WIRE IT TO ONE AS-IS.
414 Its only consumer is ``get_settings_manager_dep`` below, which is itself
415 referenced only from ``tests/web/routers/test_thread_safety.py``.
417 The hazard: FastAPI drives a *sync generator* dependency through
418 ``contextmanager_in_threadpool``, which dispatches ``__enter__`` and
419 ``__exit__`` as two SEPARATE ``anyio.to_thread.run_sync`` calls. anyio
420 picks a worker with ``idle_workers.pop()`` and gives no task affinity, so
421 the two halves can land on different pooled threads. ``enter_scope()`` /
422 ``exit_scope()`` (database/session_context.py) write to a
423 ``threading.local()``, so a straddle leaves the entering worker's
424 ``scope_depth`` stuck at >=1 forever — and ``ThreadLocalSessionManager.
425 get_session`` then permanently skips its stale-transaction rollback,
426 letting one request's uncommitted ORM state be autoflushed and committed
427 by the next request served on that thread.
429 The same hazard is documented for the streaming generators in
430 ``web/routers/library.py``, which handle it deliberately. An attempt to
431 reproduce the straddle here (120 requests, 24-way concurrency, 44 distinct
432 workers) produced no mismatch on the pinned fastapi/starlette/anyio — but
433 same-thread dispatch is a scheduling accident, not a guarantee.
435 Before using this: either make scope enter/exit a token API that asserts
436 ``threading.get_ident()`` matches, or use ``run_db_sync`` (which keeps the
437 whole unit of work on one worker), as the routes actually do.
439 A ``DatabaseSessionUnavailableError`` (a rekey sidecar or epoch fault,
440 not a credential problem) is answered with the retryable 503 of
441 ``database_unavailable_exception`` and leaves the session alone; any
442 other ``DatabaseSessionError`` clears it and answers 401.
443 """
444 from ...database.session_context import (
445 DatabaseSessionError,
446 DatabaseSessionUnavailableError,
447 )
449 session_id = request.session.get("session_id")
450 try:
451 with get_user_db_session(username, session_id=session_id) as session:
452 if session is None:
453 raise HTTPException(
454 status_code=500, detail="Failed to get database session"
455 )
456 yield session
457 except DatabaseSessionUnavailableError:
458 # require_auth's probe passed, then opening the session (or a
459 # session the handler opened) met a transient rekey fault. Clearing
460 # the session here would log the user out over an fd spike.
461 raise database_unavailable_exception() from None
462 except DatabaseSessionError:
463 # Password not available (e.g. server restarted, session expired).
464 # Clear session to force re-login.
465 request.session.clear()
466 raise HTTPException(
467 status_code=401,
468 detail="Session expired — please log in again",
469 )
472def get_settings_manager_dep(
473 db_session: Annotated[Session, Depends(get_db_session_dep)],
474 username: Annotated[str, Depends(require_auth)],
475):
476 """Yield a SettingsManager bound to the current user's DB session."""
477 return get_settings_manager(db_session, username)
480def ensure_user_database(request: Request) -> None:
481 """Ensure the user's encrypted database is open for this request.
483 Ports Flask's ensure_user_database() before_request handler.
484 Uses the same 3-source password fallback:
485 1. Temporary auth token (post-login/register, 10s TTL)
486 2. Session password store (persistent, 24h TTL)
487 3. Dummy password for unencrypted databases
489 Never raises. Any fault while resolving the password or opening the
490 connection degrades to "no connection opened", and the auth gate then
491 rejects the request with a 401 — matching Flask's handler, whose
492 ``try/except Exception`` covered both ``is_user_connected()`` and
493 ``open_user_database()`` (``web/auth/database_middleware.py`` on main).
494 That is not cosmetic here: this runs from ``DatabaseMiddleware``, i.e.
495 before any route, so an escaping exception turns EVERY authenticated
496 request into a 500 for as long as the fault lasts. One exception to the
497 401: a rekey operational fault (``RekeyOperationalError``) from the
498 reopen is recorded for the request (``request_database_scope``), and
499 the auth gate answers it with a retryable 503 that keeps the session
500 (see ``require_auth``). That includes a cached engine that cannot be
501 checked, which ``open_user_database`` raises before deriving a key, and
502 a cold open that could not take the sidecar (another holder keeping it
503 past the wait bound, an fd-exhaustion spike) or read the epoch.
504 """
505 username = request.session.get("username")
506 if not username:
507 return
509 password = None
511 # One guard around the whole body rather than main's narrower one.
512 #
513 # main's try wrapped `is_user_connected()` + `open_user_database()`
514 # together, both inside `if password:`. This port hoists the
515 # `is_user_connected()` fast path above the token block (see below),
516 # so a try around `open_user_database()` alone would leave it — and
517 # the token/password-store lookups — unguarded. Covering the lot
518 # keeps main's contract ("a db_manager fault degrades to 401") intact
519 # regardless of that reordering.
520 try:
521 # Source 1: Temporary auth token (post-login/register).
522 #
523 # Consumed BEFORE the is_user_connected() fast path below, and that
524 # ordering is load-bearing. The token is a one-time bootstrap
525 # credential that retrieve_auth() deletes from the store — but only
526 # if we actually call it. Login already opens the connection, so an
527 # early return on is_user_connected() would skip this block on every
528 # subsequent request and the token would never be consumed: it stays
529 # live in the store for its full 10s TTL *and* stays in the client's
530 # cookie. A cookie captured in that window then re-authenticates
531 # after logout, because logout clears session_password_store but
532 # cannot reach into an already-issued cookie. Worse, this block would
533 # then write the recovered password into session_password_store,
534 # promoting a 10s window into a 24h session.
535 #
536 # Flask consumed the token unconditionally here and tested
537 # is_user_connected() only at the point of opening the DB
538 # (web/auth/database_middleware.py).
539 temp_auth_token = request.session.get("temp_auth_token")
540 if temp_auth_token:
541 from ...database.temp_auth import temp_auth_store
543 auth_data = temp_auth_store.retrieve_auth(temp_auth_token)
544 if auth_data:
545 stored_username, password = auth_data
546 if stored_username == username:
547 # Remove token from session after use
548 request.session.pop("temp_auth_token", None)
550 # Store in session password store for future requests
551 session_id = request.session.get("session_id")
552 if session_id: 552 ↛ 570line 552 didn't jump to line 570 because the condition on line 552 was always true
553 session_password_store.store_session_password(
554 username, session_id, password
555 )
556 else:
557 # An expired/consumed marker is not a recovery credential.
558 # A concurrent response can retain this old cookie after
559 # another request consumed the token. Leaving it in place
560 # would bypass clear_session_if_unrecoverable indefinitely.
561 request.session.pop("temp_auth_token", None)
563 # Fast path: the connection is already open, so there is nothing left
564 # to do. Placed after the token block above rather than at the top of
565 # the function — see the comment there. In steady state (no token in
566 # the session) reaching here costs one is_user_connected() probe,
567 # which is no longer a bare dict lookup: it takes the per-user
568 # lifecycle lock and the rekey sidecar flock and re-reads the
569 # durable rekey epoch to validate the cached engine.
570 if db_manager.is_user_connected(username):
571 return
573 # Source 2: Session password store
574 if not password:
575 session_id = request.session.get("session_id")
576 if session_id:
577 password = session_password_store.get_session_password(
578 username, session_id
579 )
581 # Source 3: Dummy password for unencrypted databases
582 if not password and not db_manager.has_encryption:
583 # nosemgrep: semgrep.rules.hardcoded-secret-detection, reason: The placeholder is used only when SQLCipher encryption is unavailable
584 password = "dummy" # noqa: S105 — not a real password; placeholder for unencrypted DBs
586 if password:
587 engine = db_manager.open_user_database(username, password)
588 if not engine:
589 logger.warning(
590 f"open_user_database returned None for user {username}"
591 )
592 except RekeyOperationalError as exc:
593 # Not a verdict on the session: the sidecar or an epoch file could
594 # not be read, another holder kept the sidecar past the wait bound,
595 # or the durable rekey state needs recovery first. None of these
596 # says anything against the password. Recorded for require_auth,
597 # which answers it with the retryable 503 and keeps the session
598 # instead of reading the missing engine as a credential failure (a
599 # 401 that "/" turns into a logout). Type name only, as below.
600 _record_reopen_fault(exc)
601 logger.warning(
602 f"Database for user {username} is temporarily unavailable "
603 f"({type(exc).__name__})"
604 )
605 except Exception as exc:
606 # Deliberately no traceback and no exception message: the frames
607 # under this call hold the plaintext password in their locals, and
608 # loguru renders frame locals when `diagnose` is on (its default).
609 # `limits`-style exceptions that echo their input back would leak it
610 # into the message too. main logged a bare warning here for the same
611 # reason; the exception TYPE is added because it costs nothing and
612 # is the one piece of a traceback that cannot carry a credential.
613 logger.warning(
614 f"Failed to open database for user {username} "
615 f"({type(exc).__name__})"
616 )