Coverage for src / lexigram / admin / auth / store / lockout_sql.py: 26%

73 statements  

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

1"""SQL-backed implementation of AdminAccountLockoutStoreProtocol. 

2 

3Owns all DDL and DML for the ``admin_account_lockouts`` table. The service 

4layer depends only on ``AdminAccountLockoutStoreProtocol`` from 

5``lexigram.admin.auth.protocols`` — never on this class directly. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING, Any 

11import uuid 

12 

13from lexigram.contracts.data import DatabaseProviderProtocol 

14from lexigram.di.decorators import inject 

15from lexigram.logging import get_logger 

16 

17if TYPE_CHECKING: 

18 from datetime import datetime 

19 

20 from lexigram.admin.auth.types import AdminLockoutInfo 

21 

22from lexigram.admin.auth.types import AdminLockoutStatus 

23 

24logger = get_logger(__name__) 

25 

26_TABLE = "admin_account_lockouts" 

27 

28 

29@inject 

30class AdminAccountLockoutSqlStore: 

31 """SQL-backed store for admin account lockout records. 

32 

33 Implements ``AdminAccountLockoutStoreProtocol`` via structural subtyping. 

34 Manages the ``admin_account_lockouts`` table including DDL bootstrap, 

35 active-lockout queries, lockout creation, and lockout clearing. 

36 """ 

37 

38 def __init__(self, db: DatabaseProviderProtocol) -> None: 

39 """Initialise with a resolved database provider. 

40 

41 Args: 

42 db: Framework database provider exposing ``execute`` and 

43 ``execute_query``. 

44 """ 

45 self._db = db 

46 self._initialized = False 

47 

48 # ------------------------------------------------------------------ 

49 # Schema bootstrap (DDL) 

50 # ------------------------------------------------------------------ 

51 

52 async def ensure_schema(self) -> None: 

53 """Create table and indexes if they do not exist. 

54 

55 Safe to call multiple times — idempotent after the first successful 

56 run. Uses ``CREATE TABLE IF NOT EXISTS`` and 

57 ``CREATE UNIQUE INDEX IF NOT EXISTS`` so concurrent callers are safe. 

58 

59 Raises: 

60 Exception: Propagates any unexpected DDL failure so the caller 

61 surfaces the problem rather than silently skipping persistence. 

62 """ 

63 if self._initialized: 

64 return 

65 

66 try: 

67 await self._db.execute( 

68 """ 

69 CREATE TABLE IF NOT EXISTS admin_account_lockouts ( 

70 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 

71 email VARCHAR(255) NOT NULL, 

72 locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 

73 unlock_at TIMESTAMPTZ, 

74 consecutive_failures INTEGER NOT NULL DEFAULT 0, 

75 is_permanent BOOLEAN NOT NULL DEFAULT FALSE, 

76 is_active BOOLEAN NOT NULL DEFAULT TRUE, 

77 unlocked_at TIMESTAMPTZ, 

78 deactivated_at TIMESTAMPTZ 

79 ) 

80 """, 

81 [], 

82 ) 

83 await self._db.execute( 

84 """ 

85 CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_account_lockouts_email_active 

86 ON admin_account_lockouts(email) WHERE is_active = TRUE 

87 """, 

88 [], 

89 ) 

90 await self._db.execute( 

91 """ 

92 CREATE INDEX IF NOT EXISTS idx_admin_account_lockouts_email 

93 ON admin_account_lockouts(email, locked_at DESC) 

94 """, 

95 [], 

96 ) 

97 self._initialized = True 

98 logger.info("✅ %s schema ready", _TABLE) 

99 except Exception as _schema_err: # noqa: BLE001 — DDL may raise DB-specific errors; log and propagate 

100 logger.exception("Failed to initialise %s schema", _TABLE) 

101 raise 

102 

103 # ------------------------------------------------------------------ 

104 # DML 

105 # ------------------------------------------------------------------ 

106 

107 async def get_active_lockout(self, email: str) -> AdminLockoutInfo | None: 

108 """Get active lockout for email, or ``None`` if the account is not locked. 

109 

110 Checks ``is_active = TRUE`` and whether the lockout is still in effect 

111 (permanent, or ``unlock_at`` is in the future). Expired temporary 

112 lockouts are deactivated transparently before returning ``None``. 

113 

114 Args: 

115 email: Email address to check. 

116 

117 Returns: 

118 ``AdminLockoutInfo`` when an active lockout exists, ``None`` 

119 otherwise. 

120 """ 

121 await self.ensure_schema() 

122 sql = ( 

123 "SELECT id, email, locked_at, unlock_at, consecutive_failures, is_permanent " 

124 "FROM admin_account_lockouts " 

125 "WHERE email = ? AND is_active = TRUE" 

126 ) 

127 result = await self._db.execute_query(sql, [email]) 

128 rows = self._extract_rows(result) 

129 if not rows: 

130 return None 

131 

132 row = dict(rows[0]) 

133 is_permanent: bool = bool(row.get("is_permanent", False)) 

134 unlock_at: Any = row.get("unlock_at") 

135 

136 # A non-permanent lockout is only active while unlock_at is in the future. 

137 if not is_permanent and unlock_at is not None: 

138 # Ask the DB whether the lockout has expired — avoids timezone issues. 

139 expired_sql = ( 

140 "UPDATE admin_account_lockouts " 

141 "SET is_active = FALSE, deactivated_at = NOW() " 

142 "WHERE email = ? AND is_active = TRUE AND is_permanent = FALSE " 

143 "AND unlock_at <= NOW()" 

144 ) 

145 await self._db.execute(expired_sql, [email]) 

146 

147 # Re-check: if the row is gone, the lockout has expired. 

148 recheck_result = await self._db.execute_query(sql, [email]) 

149 recheck_rows = self._extract_rows(recheck_result) 

150 if not recheck_rows: 

151 logger.debug("lockout.expired_deactivated", email=email) 

152 return None 

153 row = dict(recheck_rows[0]) 

154 is_permanent = bool(row.get("is_permanent", False)) 

155 unlock_at = row.get("unlock_at") 

156 

157 status = ( 

158 AdminLockoutStatus.PERMANENT if is_permanent else AdminLockoutStatus.LOCKED 

159 ) 

160 

161 from lexigram.admin.auth.types import ( 

162 AdminLockoutInfo, # local import avoids circularity at module level 

163 ) 

164 

165 lockout = AdminLockoutInfo( 

166 status=status, 

167 consecutive_failures=int(row.get("consecutive_failures", 0)), 

168 locked_at=row.get("locked_at"), 

169 unlock_at=unlock_at, 

170 is_permanent=is_permanent, 

171 ) 

172 logger.debug( 

173 "lockout.active_found", 

174 email=email, 

175 status=status, 

176 consecutive_failures=lockout.consecutive_failures, 

177 ) 

178 return lockout 

179 

180 async def create_lockout( 

181 self, 

182 email: str, 

183 consecutive_failures: int, 

184 unlock_at: datetime | None, 

185 is_permanent: bool, 

186 ) -> None: 

187 """Create or replace the active lockout for email. 

188 

189 Deactivates any existing active lockout first so the unique partial 

190 index on ``(email) WHERE is_active = TRUE`` is never violated. 

191 

192 Args: 

193 email: Email address to lock. 

194 consecutive_failures: Total consecutive failure count to record. 

195 unlock_at: UTC datetime when the lock expires (``None`` if 

196 ``is_permanent`` is ``True``). 

197 is_permanent: Whether the lock requires manual admin intervention 

198 to clear. 

199 """ 

200 await self.ensure_schema() 

201 

202 # 1. Deactivate any existing active lockout. 

203 deactivate_sql = ( 

204 "UPDATE admin_account_lockouts " 

205 "SET is_active = FALSE, deactivated_at = NOW() " 

206 "WHERE email = ? AND is_active = TRUE" 

207 ) 

208 await self._db.execute(deactivate_sql, (email,)) 

209 

210 # 2. Insert the new lockout record. 

211 lockout_id = str(uuid.uuid4()) 

212 insert_sql = ( 

213 "INSERT INTO admin_account_lockouts " 

214 "(id, email, consecutive_failures, unlock_at, is_permanent, is_active) " 

215 "VALUES (?, ?, ?, ?, ?, TRUE)" 

216 ) 

217 await self._db.execute( 

218 insert_sql, 

219 ( 

220 lockout_id, 

221 email, 

222 consecutive_failures, 

223 unlock_at, 

224 is_permanent, 

225 ), 

226 ) 

227 logger.info( 

228 "lockout.created", 

229 email=email, 

230 consecutive_failures=consecutive_failures, 

231 is_permanent=is_permanent, 

232 unlock_at=str(unlock_at) if unlock_at is not None else None, 

233 ) 

234 

235 async def clear_lockout(self, email: str) -> None: 

236 """Deactivate any active lockout for email. 

237 

238 Called on successful login or explicit admin unlock. Safe to call 

239 when no active lockout exists — the UPDATE simply affects zero rows. 

240 

241 Args: 

242 email: Email address to unlock. 

243 """ 

244 await self.ensure_schema() 

245 sql = ( 

246 "UPDATE admin_account_lockouts " 

247 "SET is_active = FALSE, unlocked_at = NOW() " 

248 "WHERE email = ? AND is_active = TRUE" 

249 ) 

250 await self._db.execute(sql, (email,)) 

251 logger.debug("lockout.cleared", email=email) 

252 

253 # ------------------------------------------------------------------ 

254 # Internal helpers 

255 # ------------------------------------------------------------------ 

256 

257 @staticmethod 

258 def _extract_rows(result: Any) -> list[Any]: 

259 """Normalise heterogeneous query result shapes into a plain list. 

260 

261 Args: 

262 result: Raw result returned by ``execute_query``. 

263 

264 Returns: 

265 A list of row-like objects (dicts or record proxies). 

266 """ 

267 if hasattr(result, "rows"): 

268 return list(result.rows) 

269 if isinstance(result, list): 

270 return result 

271 return [] 

272 

273 

274__all__ = ["AdminAccountLockoutSqlStore"]