Coverage for src / lexigram / admin / auth / store / direct_sql.py: 9%

223 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1""" 

2Direct SQL admin user store implementation. 

3""" 

4 

5from __future__ import annotations 

6 

7from typing import Any 

8import uuid 

9 

10from lexigram.contracts.data import DatabaseProviderProtocol 

11from lexigram.di.decorators import inject 

12from lexigram.logging import get_logger 

13 

14logger = get_logger(__name__) 

15 

16 

17@inject 

18class DirectSQLAdminUserStore: 

19 """Simple store implementation for admin_users table using direct SQL. 

20 

21 This class provides the subset of operations required by AdminAuthAdapter 

22 (create_user, get_user_by_username, update_user, delete_user). 

23 Renamed from AdminUserStore to avoid conflict with config-backed store. 

24 """ 

25 

26 def __init__(self, db_provider: DatabaseProviderProtocol) -> None: 

27 self.db_provider = db_provider 

28 self._initialized = False 

29 

30 async def _ensure_table_exists(self) -> None: 

31 """Ensure admin_users table exists (create if needed).""" 

32 if self._initialized: 

33 return 

34 

35 try: 

36 # Table check (supports both Postgres and SQLite) 

37 db_type = getattr(self.db_provider, "database_type", "") or "" 

38 exists = False 

39 

40 if db_type.lower() in ("postgres", "postgresql"): 

41 check_sql = "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'admin_users')" 

42 result = await self.db_provider.execute_query(check_sql, []) 

43 if hasattr(result, "rows") and result.rows: 

44 exists = result.rows[0].get("exists", False) 

45 elif isinstance(result, list) and result: 

46 exists = result[0].get("exists", False) 

47 else: 

48 # SQLite fallback 

49 check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='admin_users'" 

50 result = await self.db_provider.execute_query(check_sql, []) 

51 # Direct check if result has any rows 

52 if hasattr(result, "rows"): 

53 exists = len(result.rows) > 0 

54 elif isinstance(result, list): 

55 exists = len(result) > 0 

56 else: 

57 exists = bool(result) 

58 

59 logger.debug("admin_users exists=%s (db_type=%s)", exists, db_type) 

60 

61 if not exists: 

62 logger.info("Table 'admin_users' not found; creating it...") 

63 # Note: UUID in SQLite is TEXT, JSONB is TEXT 

64 sql = """ 

65 CREATE TABLE admin_users ( 

66 id VARCHAR(255) PRIMARY KEY, 

67 name VARCHAR(255) NOT NULL, 

68 email VARCHAR(255) UNIQUE NOT NULL, 

69 hashed_password TEXT, 

70 roles TEXT, 

71 permissions TEXT, 

72 is_active BOOLEAN DEFAULT true, 

73 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

74 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

75 ) 

76 """ 

77 if db_type.lower() in ("postgres", "postgresql"): 

78 sql = """ 

79 CREATE TABLE admin_users ( 

80 id VARCHAR(255) PRIMARY KEY, 

81 name VARCHAR(255) NOT NULL, 

82 email VARCHAR(255) UNIQUE NOT NULL, 

83 hashed_password TEXT, 

84 roles JSONB, 

85 permissions JSONB, 

86 is_active BOOLEAN DEFAULT true, 

87 created_at TIMESTAMPTZ DEFAULT NOW(), 

88 updated_at TIMESTAMPTZ DEFAULT NOW() 

89 ) 

90 """ 

91 

92 await self.db_provider.execute(sql, []) 

93 logger.info("✅ admin_users table created successfully") 

94 else: 

95 logger.debug("Table 'admin_users' already exists") 

96 

97 self._initialized = True 

98 except Exception as _schema_err: # noqa: BLE001 — schema setup may fail with DB-specific errors; log and propagate 

99 logger.exception("Failed to ensure admin_users table exists") 

100 raise 

101 

102 async def get_admin_count(self) -> int: 

103 """Count total admin users.""" 

104 await self._ensure_table_exists() 

105 sql = "SELECT COUNT(*) as count FROM admin_users" 

106 result = await self.db_provider.execute_query(sql, []) 

107 

108 if hasattr(result, "rows") and result.rows: 

109 return result.rows[0].get("count", 0) 

110 if isinstance(result, list) and result: 

111 return result[0].get("count", 0) 

112 if isinstance(result, dict): 

113 return result.get("count", 0) 

114 

115 return 0 

116 

117 async def create_user( 

118 self, 

119 name: str, 

120 email: str, 

121 hashed_password: str, 

122 roles: list[str] | None = None, 

123 permissions: list[str] | None = None, 

124 **kwargs, # Accept and ignore extra parameters like 'profile' 

125 ) -> Any: 

126 await self._ensure_table_exists() 

127 admin_id = str(uuid.uuid4()) 

128 payload = { 

129 "id": admin_id, 

130 "name": name, 

131 "email": email, 

132 "hashed_password": hashed_password, 

133 "roles": roles or [], 

134 "permissions": permissions or [], 

135 "is_active": True, 

136 } 

137 

138 # Attempt an atomic upsert when running against Postgres to avoid races 

139 db_type = getattr(self.db_provider, "database_type", "") or "" 

140 if db_type.lower() in ("postgres", "postgresql"): 

141 # Use RETURNING to fetch created/updated row atomically. 

142 # Explicit ::jsonb casts are required because asyncpg cannot 

143 # infer the column type for unparameterised JSONB columns 

144 # (DataError: expected str, got list). 

145 sql = ( 

146 "INSERT INTO admin_users (id, name, email, hashed_password, roles, permissions, is_active) " 

147 "VALUES (?, ?, ?, ?, ?, ?, ?) " 

148 "ON CONFLICT (email) DO UPDATE SET " 

149 "name = EXCLUDED.name, hashed_password = EXCLUDED.hashed_password, " 

150 "roles = EXCLUDED.roles, permissions = EXCLUDED.permissions, is_active = EXCLUDED.is_active, updated_at = NOW() " 

151 "RETURNING id, name, email" 

152 ) 

153 params = [ 

154 admin_id, 

155 name, 

156 email, 

157 hashed_password, 

158 roles or [], 

159 permissions or [], 

160 True, 

161 ] 

162 try: 

163 result = await self.db_provider.execute(sql, params) 

164 # db_provider.execute() returns a QueryResult object 

165 if hasattr(result, "success") and not result.success: 

166 raise RuntimeError( 

167 f"UPSERT failed: {getattr(result, 'error_message', 'unknown error')}" 

168 ) 

169 # Extract row from QueryResult.rows 

170 row = None 

171 if hasattr(result, "rows") and result.rows: 

172 row = result.rows[0] 

173 elif isinstance(result, list) and result: 

174 row = result[0] 

175 elif isinstance(result, dict): 

176 row = result 

177 

178 if row: 

179 

180 class _CreatedUser: 

181 def __init__(self, uid: str, name: str, email: str) -> None: 

182 self.user_id = uid 

183 self.name = name 

184 self.username = name 

185 self.email = email 

186 

187 logger.info("Created or updated admin user %s via upsert", name) 

188 return _CreatedUser( 

189 str(row.get("id")), 

190 str(row.get("name") or ""), 

191 str(row.get("email") or ""), 

192 ) 

193 except Exception as _upsert_err: # noqa: BLE001 — pragma: no cover - fallback path exercised by tests; DB upsert may raise DB-specific errors 

194 # Fall through to non-upsert approach below 

195 logger.exception( 

196 "Postgres upsert for admin_users failed, falling back to insert" 

197 ) 

198 

199 # Fallback (DB-agnostic): try a manual insert with JSONB casts for Postgres, 

200 # falling back to execute_insert for other databases. 

201 try: 

202 if db_type.lower() in ("postgres", "postgresql"): 

203 # Use explicit ::jsonb casts for Postgres 

204 fallback_sql = ( 

205 "INSERT INTO admin_users (id, name, email, hashed_password, roles, permissions, is_active) " 

206 "VALUES (?, ?, ?, ?, ?, ?, ?)" 

207 ) 

208 fallback_params = [ 

209 admin_id, 

210 name, 

211 email, 

212 hashed_password, 

213 roles or [], 

214 permissions or [], 

215 True, 

216 ] 

217 fallback_result = await self.db_provider.execute( 

218 fallback_sql, fallback_params 

219 ) 

220 if hasattr(fallback_result, "success") and not fallback_result.success: 

221 raise RuntimeError( 

222 f"Fallback INSERT failed: {getattr(fallback_result, 'error_message', 'unknown error')}" 

223 ) 

224 else: 

225 await self.db_provider.execute_insert("admin_users", payload) 

226 

227 # Return lightweight created object similar shape used by adapter 

228 class _CreatedUser: # type: ignore[no-redef] 

229 def __init__(self, uid: str, name: str, email: str) -> None: 

230 self.user_id = uid 

231 self.name = name 

232 self.username = name 

233 self.email = email 

234 

235 logger.info("Created admin user %s via AdminUserStore", name) 

236 return _CreatedUser(admin_id, name, email) 

237 except Exception as e: # noqa: BLE001 — duplicate-key detection requires inspecting the exception type from any DB driver 

238 # Handle duplicate-key errors by resolving existing user and updating if needed 

239 err_str = str(e).lower() 

240 from lexigram.contracts.exceptions import DuplicateKeyError 

241 

242 if ( 

243 isinstance(e, DuplicateKeyError) 

244 or "duplicate key" in err_str 

245 or "unique constraint" in err_str 

246 ): 

247 logger.info( 

248 "AdminUserStore.create_user detected existing user for %s; resolving existing user", 

249 name, 

250 ) 

251 existing = None 

252 try: 

253 existing = await self.get_user_by_email(email) 

254 except (RuntimeError, ValueError, OSError): 

255 existing = None 

256 

257 if existing: 

258 # Optionally update roles/permissions/hashed_password 

259 try: 

260 # Populate missing attrs if provided 

261 existing_roles = getattr(existing, "roles", []) or [] 

262 if existing_roles != (roles or []): 

263 existing.roles = roles or [] 

264 existing_perms = getattr(existing, "permissions", []) or [] 

265 if existing_perms != (permissions or []): 

266 existing.permissions = permissions or [] 

267 

268 existing_hash = getattr(existing, "hashed_password", None) 

269 if hashed_password and existing_hash != hashed_password: 

270 existing.hashed_password = hashed_password 

271 await self.update_user(existing) 

272 logger.info( 

273 "Updated existing admin user %s after duplicate create", 

274 name, 

275 ) 

276 except BaseException: 

277 logger.exception( 

278 "Failed to update existing admin user %s after duplicate create", 

279 name, 

280 ) 

281 

282 # Return lightweight object 

283 class _ExistingUser: 

284 def __init__(self, uid: str, name: str, email: str) -> None: 

285 self.user_id = uid 

286 self.name = name 

287 self.email = email 

288 

289 return _ExistingUser( 

290 str( 

291 getattr( 

292 existing, "user_id", getattr(existing, "id", None) or "" 

293 ) 

294 ), 

295 str(existing.name or ""), 

296 str(existing.email or ""), 

297 ) 

298 

299 # Re-raise if it's some other error 

300 logger.exception( 

301 "DirectSQLAdminUserStore.create_user failed for %s", 

302 name, 

303 ) 

304 raise 

305 

306 # Also need helper for email lookup if we use it in logic 

307 async def get_user_by_email(self, email: str) -> Any | None: 

308 await self._ensure_table_exists() 

309 from lexigram.logging import get_logger 

310 

311 logger = get_logger(__name__) 

312 

313 sql = "SELECT * FROM admin_users WHERE email = ?" 

314 result = await self.db_provider.execute_query(sql, [email]) 

315 logger.debug( 

316 "get_user_by_email result type: %s, result: %s", 

317 type(result), 

318 result, 

319 ) 

320 

321 row = None 

322 if hasattr(result, "rows") and result.rows: 

323 row = result.rows[0] 

324 elif hasattr(result, "fetchone"): 

325 row = result.fetchone() 

326 elif isinstance(result, dict): 

327 row = result 

328 elif isinstance(result, list) and result: 

329 row = result[0] if result else None 

330 

331 logger.debug("Parsed row: %s", row) 

332 

333 if not row: 

334 logger.debug("No user found with email: %s", email) 

335 return None 

336 

337 logger.debug( 

338 "Found user by email: %s, is_active: %s, has_password: %s", 

339 email, 

340 row.get("is_active"), 

341 bool(row.get("hashed_password")), 

342 ) 

343 

344 class _UserObj: 

345 def __init__(self, row: dict[str, Any]) -> None: 

346 self.user_id = str(row.get("id") or row.get("user_id")) 

347 self.name = row.get("name") 

348 self.email = row.get("email") 

349 # Add attributes needed for update 

350 self.roles = row.get("roles", []) 

351 self.permissions = row.get("permissions", []) 

352 self.hashed_password = row.get("hashed_password") 

353 self.is_active = row.get("is_active") 

354 

355 def record_login(self) -> Any: 

356 """Record login - no-op for admin users""" 

357 

358 return _UserObj(row) 

359 

360 async def get_user_by_id(self, user_id: str) -> Any | None: 

361 await self._ensure_table_exists() 

362 from lexigram.logging import get_logger 

363 

364 logger = get_logger(__name__) 

365 

366 sql = "SELECT * FROM admin_users WHERE id = ?" 

367 result = await self.db_provider.execute_query(sql, [user_id]) 

368 row = None 

369 if hasattr(result, "rows") and result.rows: 

370 row = result.rows[0] 

371 elif hasattr(result, "fetchone"): 

372 row = result.fetchone() 

373 elif isinstance(result, dict): 

374 row = result 

375 elif isinstance(result, list) and result: 

376 row = result[0] 

377 

378 if not row: 

379 logger.debug("No user found with id: %s", user_id) 

380 return None 

381 

382 logger.debug( 

383 "Found user by id: %s, is_active: %s", 

384 user_id, 

385 row.get("is_active"), 

386 ) 

387 

388 class _UserObj: 

389 def __init__(self, row: dict[str, Any]) -> None: 

390 self.user_id = str(row.get("id") or row.get("user_id")) 

391 self.name = row.get("name") 

392 self.email = row.get("email") 

393 self.roles = row.get("roles", []) 

394 self.permissions = row.get("permissions", []) 

395 self.hashed_password = row.get("hashed_password") 

396 self.is_active = row.get("is_active") 

397 

398 def record_login(self) -> Any: 

399 """Record login - no-op for admin users""" 

400 

401 return _UserObj(row) 

402 

403 async def update_user(self, user: Any) -> None: 

404 await self._ensure_table_exists() 

405 payload = { 

406 "name": user.name, 

407 "email": user.email, 

408 "hashed_password": getattr(user, "hashed_password", None), 

409 "roles": getattr(user, "roles", []), 

410 "permissions": getattr(user, "permissions", []), 

411 "is_active": getattr(user, "is_active", True), 

412 } 

413 await self.db_provider.execute_update( 

414 "admin_users", 

415 payload, 

416 "id = ?", 

417 [user.user_id], 

418 ) 

419 

420 async def authenticate(self, email: str, password: str) -> Any | None: 

421 """Authenticate an admin user by email and bcrypt-hashed password. 

422 

423 Args: 

424 email: Email address to look up. 

425 password: Plain-text password to verify against the stored hash. 

426 

427 Returns: 

428 User object when credentials are valid and account is active, 

429 ``None`` otherwise. 

430 """ 

431 user = await self.get_user_by_email(email) 

432 if not user: 

433 return None 

434 if not getattr(user, "is_active", True): 

435 return None 

436 hashed = getattr(user, "hashed_password", None) 

437 if not hashed: 

438 return None 

439 try: 

440 import bcrypt 

441 

442 hashed_bytes = hashed.encode("utf-8") if isinstance(hashed, str) else hashed 

443 if bcrypt.checkpw(password.encode("utf-8"), hashed_bytes): 

444 return user 

445 except (ValueError, TypeError) as exc: 

446 logger.warning( 

447 "authenticate.password_check_failed", email=email, error=str(exc) 

448 ) 

449 return None 

450 

451 async def delete_user(self, user_id: str) -> None: 

452 await self._ensure_table_exists() 

453 await self.db_provider.execute_delete("admin_users", "id = ?", [user_id]) 

454 

455 async def get_by_id(self, admin_id: str) -> Any | None: 

456 """Alias for get_user_by_id — used by AdminAuthMiddleware.""" 

457 return await self.get_user_by_id(admin_id)