Coverage for src/local_deep_research/database/auth_db.py: 93%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Authentication database initialization and management. 

3This manages the central ldr_auth.db which only stores usernames. 

4""" 

5 

6import errno 

7import os 

8import threading 

9from contextlib import contextmanager 

10from pathlib import Path 

11from typing import Optional 

12 

13from loguru import logger 

14from sqlalchemy import create_engine, event 

15from sqlalchemy.engine import Engine 

16from sqlalchemy.orm import sessionmaker, Session 

17from sqlalchemy.pool import QueuePool 

18from sqlalchemy.schema import CreateIndex, CreateTable 

19 

20from ..config.paths import get_data_directory 

21from .models.auth import User 

22from .pool_config import POOL_PRE_PING, POOL_RECYCLE_SECONDS 

23 

24 

25# Global cached engine for auth database to prevent file descriptor leaks 

26_auth_engine: Optional[Engine] = None 

27_auth_engine_path: Optional[Path] = ( 

28 None # Track the path the engine was created for 

29) 

30_auth_engine_lock = threading.Lock() 

31 

32 

33def get_auth_db_path() -> Path: 

34 """Get the path to the authentication database.""" 

35 return get_data_directory() / "ldr_auth.db" 

36 

37 

38def _best_effort_chmod(path: Path, mode: int) -> None: 

39 """Tighten permissions on the auth DB file to ``mode``, never raising. 

40 

41 Mirrors ``encrypted_db._best_effort_chmod``: permission hardening 

42 must NOT be able to break auth database creation. On filesystems 

43 where chmod raises even for the owner (some Docker bind mounts and 

44 network/FUSE volumes), the file stays at its umask default and the 

45 downgrade is surfaced at warning level — ``ldr_auth.db`` holds 

46 usernames and login metadata, so a silent group/world-readable 

47 state deserves visibility. 

48 """ 

49 try: 

50 os.chmod(str(path), mode) 

51 except OSError as exc: 

52 # Copy out before logging: the check-sensitive-logging pre-commit 

53 # hook flags any reference to the ``except`` variable itself inside 

54 # a non-debug logger call (including its attributes, since e.g. 

55 # e.strerror carries the same detail as str(e)) as a potential 

56 # secret leak. errno/strerror are plain OS error codes/text, not 

57 # sensitive, but the hook can't tell that apart -- so, like 

58 # encrypted_db._db_path_is_symlinked's ``target = f"...{exc 

59 # .strerror}>"``, the detail is copied into ordinary local 

60 # variables first and only those are interpolated below. 

61 chmod_errno = exc.errno 

62 chmod_strerror = exc.strerror 

63 logger.warning( 

64 f"Could not set permissions {oct(mode)} on auth database " 

65 f"{path}; left at filesystem default " 

66 f"(errno={chmod_errno}, {chmod_strerror})" 

67 ) 

68 

69 

70def _create_private_file(path: Path) -> None: 

71 """Create ``path`` as an empty, owner-only regular file, never via a link. 

72 

73 Mirrors ``encrypted_db._create_private_file``: ``O_CREAT | O_EXCL`` 

74 fails when anything exists at ``path`` — including a symlink, dangling 

75 or not — and ``O_NOFOLLOW`` is added where the platform has it, so a 

76 planted symlink is refused rather than followed. This closes the 

77 creation-race window where SQLite would otherwise create the file at 

78 umask-default permissions (typically 0644) before the post-DDL chmod 

79 ever runs. Callers tolerate ``FileExistsError`` — the file may already 

80 exist from a previous run, in which case SQLite simply opens it and the 

81 existing-file chmod paths (here and in ``_get_auth_engine``) tighten it. 

82 """ 

83 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) 

84 try: 

85 fd = os.open(str(path), flags, 0o600) 

86 except OSError as exc: 

87 # A platform may report the symlink through O_NOFOLLOW (ELOOP) 

88 # before O_EXCL gets to say EEXIST; both mean "something is 

89 # already at this path", so callers see one exception type. 

90 if exc.errno == errno.ELOOP: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 raise FileExistsError( 

92 errno.EEXIST, "Path is a symlink", str(path) 

93 ) from exc 

94 raise 

95 os.close(fd) 

96 

97 

98def _get_auth_engine() -> Engine: 

99 """ 

100 Get or create a cached engine for the auth database. 

101 

102 This prevents file descriptor leaks by reusing a single engine 

103 instead of creating a new one for every session. 

104 

105 The engine is invalidated if the data directory path changes 

106 (e.g., during testing when LDR_DATA_DIR is set to a temp directory). 

107 """ 

108 global _auth_engine, _auth_engine_path 

109 

110 auth_db_path = get_auth_db_path() 

111 

112 # Check if we have a cached engine for the current path 

113 if _auth_engine is not None and _auth_engine_path == auth_db_path: 

114 return _auth_engine 

115 

116 with _auth_engine_lock: 

117 # Double-check after acquiring lock 

118 if _auth_engine is not None and _auth_engine_path == auth_db_path: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true

119 return _auth_engine 

120 

121 # If path changed, dispose old engine first 

122 if _auth_engine is not None and _auth_engine_path != auth_db_path: 

123 try: 

124 _auth_engine.dispose() 

125 logger.debug( 

126 "Disposed auth engine due to data directory change" 

127 ) 

128 except Exception: 

129 logger.warning("Error disposing old auth engine") 

130 _auth_engine = None 

131 _auth_engine_path = None 

132 

133 # Ensure database exists 

134 if not auth_db_path.exists(): 

135 init_auth_database() 

136 

137 # SECURITY: tighten permissions whenever the engine is (re)created 

138 # for this path. init_auth_database() already does this for a 

139 # fresh database, but this call also covers an existing file from 

140 # an older install that predates owner-only creation (still 0644) 

141 # — upgrading and restarting is then enough to tighten it, with no 

142 # migration step. Best-effort and idempotent; this block runs at 

143 # most once per process per path since the cached-engine check 

144 # above short-circuits every later call for the same path. 

145 _best_effort_chmod(auth_db_path, 0o600) 

146 

147 # Moderate pool — auth DB is unencrypted SQLite used by every 

148 # authenticated request via before_request middleware. 

149 # See ADR-0004 for pool sizing rationale. 

150 _auth_engine = create_engine( 

151 f"sqlite:///{auth_db_path}", 

152 poolclass=QueuePool, 

153 pool_size=10, 

154 max_overflow=20, 

155 pool_pre_ping=POOL_PRE_PING, 

156 pool_recycle=POOL_RECYCLE_SECONDS, 

157 echo=False, 

158 ) 

159 

160 def _apply_auth_pragmas(dbapi_connection, connection_record): 

161 dbapi_connection.execute("PRAGMA busy_timeout = 10000") 

162 dbapi_connection.execute("PRAGMA temp_store = MEMORY") 

163 

164 event.listen(_auth_engine, "connect", _apply_auth_pragmas) 

165 

166 _auth_engine_path = auth_db_path 

167 logger.debug("Created cached auth database engine") 

168 

169 return _auth_engine 

170 

171 

172def init_auth_database(): 

173 """Initialize the authentication database if it doesn't exist. 

174 

175 Uses SQL-level IF NOT EXISTS (via CreateTable/CreateIndex) so that 

176 concurrent calls are handled atomically by the database engine, 

177 eliminating the TOCTOU race in SQLAlchemy's checkfirst inspect. 

178 """ 

179 auth_db_path = get_auth_db_path() 

180 

181 # Ensure the data directory exists. 

182 # Lazy import: this runs during early app bootstrap, so avoid a 

183 # top-level security -> ... -> database import cycle. 

184 from ..security.directory_creation import create_directory 

185 

186 create_directory( 

187 auth_db_path.parent, 

188 context="auth database directory", 

189 ) 

190 

191 logger.debug(f"Ensuring auth database at {auth_db_path}") 

192 

193 # SECURITY: materialize the file ourselves, owner-only and no-follow, 

194 # before handing the path to SQLite. Without this, SQLite's own lazy 

195 # create below lands the file at umask-default permissions (typically 

196 # 0644) for the window between that create and the post-DDL chmod 

197 # further down — a same-host reader with an fd opened in that window 

198 # keeps read access even after the chmod. This call is idempotent and 

199 # is what init_auth_database's callers rely on for repeated/concurrent 

200 # invocations (see the "IF NOT EXISTS" DDL below), so a FileExistsError 

201 # from an already-present file is expected and tolerated — but only 

202 # when it is a regular file. init_auth_database() only runs when 

203 # _get_auth_engine() sees auth_db_path.exists() is False, which is 

204 # also true for a dangling symlink, so a dangling symlink at this 

205 # path is caught right here and refused. An existing database file — 

206 # including one reached through a symlink to an existing target — 

207 # never reaches this function at all: _get_auth_engine() opens it as 

208 # before via create_engine() and tightens it with its own chmod, 

209 # following the link the same way SQLite's open always has. 

210 try: 

211 _create_private_file(auth_db_path) 

212 except FileExistsError: 

213 if auth_db_path.is_symlink(): 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 raise ValueError( 

215 f"Auth database path {auth_db_path} is a symlink; " 

216 "refusing to create or open the database there" 

217 ) 

218 

219 # Create the database with a temporary engine 

220 engine = create_engine(f"sqlite:///{auth_db_path}") 

221 

222 # Use SQL-level IF NOT EXISTS — idempotent and atomic, safe for 

223 # concurrent calls without Python-level locking. 

224 with engine.begin() as conn: 

225 conn.execute(CreateTable(User.__table__, if_not_exists=True)) 

226 for index in User.__table__.indexes: 

227 conn.execute(CreateIndex(index, if_not_exists=True)) 

228 

229 # Dispose the temporary engine 

230 engine.dispose() 

231 

232 # SECURITY: SQLite creates the file with umask-default permissions 

233 # (typically 0644), while every other DB artifact is owner-only. 

234 # Tighten to owner read/write — best-effort so a chmod-hostile 

235 # filesystem cannot break auth database creation. 

236 _best_effort_chmod(auth_db_path, 0o600) 

237 

238 logger.debug("Auth database initialized successfully") 

239 

240 

241def get_auth_db_session() -> Session: 

242 """ 

243 Get a session for the auth database. 

244 

245 IMPORTANT: The caller MUST close the session when done to return 

246 the connection to the pool. Use auth_db_session() context manager 

247 for automatic cleanup. 

248 """ 

249 engine = _get_auth_engine() 

250 SessionFactory = sessionmaker(bind=engine) 

251 return SessionFactory() 

252 

253 

254@contextmanager 

255def auth_db_session(): 

256 """ 

257 Context manager for auth database sessions. 

258 

259 Usage: 

260 with auth_db_session() as session: 

261 user = session.query(User).filter_by(username=username).first() 

262 

263 The session is automatically closed when the context exits. 

264 """ 

265 session = get_auth_db_session() 

266 try: 

267 yield session 

268 finally: 

269 from ..utilities.resource_utils import safe_close 

270 

271 safe_close(session, "auth DB session") 

272 

273 

274def dispose_auth_engine(): 

275 """ 

276 Dispose the auth engine (for shutdown/cleanup). 

277 """ 

278 global _auth_engine, _auth_engine_path 

279 

280 with _auth_engine_lock: 

281 if _auth_engine is not None: 

282 _auth_engine.dispose() 

283 _auth_engine = None 

284 _auth_engine_path = None 

285 logger.debug("Disposed auth database engine")