Coverage for src / lexigram / admin / auth / store / audit_log_sql.py: 31%

85 statements  

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

1from __future__ import annotations 

2 

3"""SQL-backed implementation of AdminAuditLogStoreProtocol. 

4 

5This is the *only* module in ``lexigram-admin`` that issues raw SQL against 

6the ``admin_security_audit_log`` table. The audit service layer depends on 

7the ``AdminAuditLogStoreProtocol`` protocol and never on this class directly. 

8""" 

9 

10from datetime import datetime 

11from typing import Any 

12import uuid 

13 

14from lexigram.admin.auth.types import AdminSecurityEvent, AdminSecurityEventType 

15from lexigram.contracts.data import DatabaseProviderProtocol 

16from lexigram.di.decorators import inject 

17from lexigram.logging import get_logger 

18from lexigram.serialization import dumps_str, loads_str 

19 

20logger = get_logger(__name__) 

21 

22_TABLE = "admin_security_audit_log" 

23 

24_CREATE_TABLE_SQL = f""" 

25 CREATE TABLE IF NOT EXISTS {_TABLE} ( 

26 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 

27 event_type VARCHAR(50) NOT NULL, 

28 admin_user_id UUID, 

29 ip_address VARCHAR(45) NOT NULL, 

30 user_agent TEXT NOT NULL DEFAULT '', 

31 success BOOLEAN NOT NULL DEFAULT FALSE, 

32 metadata TEXT NOT NULL DEFAULT '{{}}', 

33 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 

34 ) 

35""" 

36 

37_CREATE_INDEX_CREATED_AT = ( 

38 f"CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at" 

39 f" ON {_TABLE}(created_at DESC)" 

40) 

41 

42_CREATE_INDEX_USER_ID = ( 

43 f"CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin_user_id" 

44 f" ON {_TABLE}(admin_user_id, created_at DESC) WHERE admin_user_id IS NOT NULL" 

45) 

46 

47_CREATE_INDEX_EVENT_TYPE = ( 

48 f"CREATE INDEX IF NOT EXISTS idx_admin_audit_log_event_type" 

49 f" ON {_TABLE}(event_type, created_at DESC)" 

50) 

51 

52 

53@inject 

54class AdminAuditLogSqlStore: 

55 """SQL implementation of AdminAuditLogStoreProtocol. 

56 

57 Stores security audit events in the ``admin_security_audit_log`` table. 

58 All operations are fire-tolerant — exceptions are logged but never 

59 re-raised from ``insert()`` since audit failure must not block 

60 authentication. 

61 

62 Implements ``AdminAuditLogStoreProtocol`` via structural subtyping. 

63 """ 

64 

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

66 """Initialize with a resolved database provider. 

67 

68 Args: 

69 db: Framework database provider that exposes ``execute``, 

70 ``execute_query``, and ``execute_insert``. 

71 """ 

72 self._db = db 

73 self._initialized = False 

74 

75 # ------------------------------------------------------------------ 

76 # Schema bootstrap (DDL) 

77 # ------------------------------------------------------------------ 

78 

79 async def ensure_schema(self) -> None: 

80 """Create the audit log table and indexes if they do not exist. 

81 

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

83 run. Raises on any unexpected DDL failure so callers surface the 

84 problem rather than silently skipping audit persistence. 

85 """ 

86 if self._initialized: 

87 return 

88 

89 try: 

90 await self._db.execute(_CREATE_TABLE_SQL, []) 

91 logger.info("✅ %s table ensured", _TABLE) 

92 

93 for index_sql in ( 

94 _CREATE_INDEX_CREATED_AT, 

95 _CREATE_INDEX_USER_ID, 

96 _CREATE_INDEX_EVENT_TYPE, 

97 ): 

98 try: 

99 await self._db.execute(index_sql, []) 

100 except (RuntimeError, ValueError, OSError) as exc: 

101 logger.debug("Index creation skipped: %s", exc) 

102 

103 logger.info("✅ %s indexes ensured", _TABLE) 

104 self._initialized = True 

105 

106 except Exception: # noqa: BLE001 — schema DDL may raise DB-specific errors; log and propagate 

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

108 raise 

109 

110 # ------------------------------------------------------------------ 

111 # AdminAuditLogStoreProtocol implementation 

112 # ------------------------------------------------------------------ 

113 

114 async def insert(self, event: AdminSecurityEvent) -> None: 

115 """Persist a security audit event. 

116 

117 This method **never raises** — exceptions are caught and logged at 

118 warning level. Audit failure must never block the authentication flow. 

119 

120 Args: 

121 event: Security event to store. 

122 """ 

123 await self.ensure_schema() 

124 

125 try: 

126 payload = { 

127 "id": uuid.UUID(event.id) if _is_uuid_string(event.id) else event.id, 

128 "event_type": event.event_type.value, 

129 "admin_user_id": ( 

130 uuid.UUID(event.admin_user_id) 

131 if event.admin_user_id and _is_uuid_string(event.admin_user_id) 

132 else event.admin_user_id 

133 ), 

134 "ip_address": event.ip_address, 

135 "user_agent": event.user_agent, 

136 "success": event.success, 

137 "metadata": dumps_str(event.metadata), 

138 "created_at": event.created_at, 

139 } 

140 await self._db.execute_insert(_TABLE, payload) 

141 logger.debug( 

142 "audit_log.inserted", 

143 event_id=event.id, 

144 event_type=event.event_type.value, 

145 admin_user_id=event.admin_user_id, 

146 ) 

147 

148 except Exception as exc: # noqa: BLE001 — audit insert must never surface errors to callers 

149 logger.warning( 

150 "audit_log.insert_failed", 

151 event_id=event.id, 

152 event_type=event.event_type.value, 

153 error=str(exc), 

154 ) 

155 

156 async def query_recent( 

157 self, 

158 admin_user_id: str | None = None, 

159 event_type: AdminSecurityEventType | None = None, 

160 since_seconds: int = 3600, 

161 limit: int = 100, 

162 ) -> list[AdminSecurityEvent]: 

163 """Query recent security events with optional filters. 

164 

165 Args: 

166 admin_user_id: Filter to a specific user (``None`` = all users). 

167 event_type: Filter to a specific event type (``None`` = all types). 

168 since_seconds: Look-back window in seconds. 

169 limit: Maximum number of records to return. 

170 

171 Returns: 

172 List of ``AdminSecurityEvent`` instances ordered newest first. 

173 """ 

174 await self.ensure_schema() 

175 

176 # ``since_seconds`` is always an int — safe to interpolate directly. 

177 conditions = [f"created_at > NOW() - INTERVAL '{int(since_seconds)} seconds'"] 

178 params: list[Any] = [] 

179 

180 if admin_user_id is not None: 

181 params.append( 

182 uuid.UUID(admin_user_id) 

183 if _is_uuid_string(admin_user_id) 

184 else admin_user_id 

185 ) 

186 conditions.append("admin_user_id = ?") 

187 

188 if event_type is not None: 

189 params.append(event_type.value) 

190 conditions.append("event_type = ?") 

191 

192 where_clause = " AND ".join(conditions) 

193 sql = ( 

194 f"SELECT id, event_type, admin_user_id, ip_address, user_agent," 

195 f" success, metadata, created_at" 

196 f" FROM {_TABLE}" 

197 f" WHERE {where_clause}" 

198 f" ORDER BY created_at DESC" 

199 f" LIMIT {int(limit)}" 

200 ) 

201 

202 try: 

203 result = await self._db.execute_query(sql, params) 

204 rows = _extract_rows(result) 

205 return [_row_to_event(row) for row in rows] 

206 

207 except Exception as exc: # noqa: BLE001 — query failures must not crash callers; return empty list 

208 logger.warning( 

209 "audit_log.query_failed", 

210 since_seconds=since_seconds, 

211 admin_user_id=admin_user_id, 

212 event_type=event_type.value if event_type else None, 

213 error=str(exc), 

214 ) 

215 return [] 

216 

217 

218# ------------------------------------------------------------------ 

219# Module-private helpers 

220# ------------------------------------------------------------------ 

221 

222 

223def _is_uuid_string(value: str) -> bool: 

224 """Return ``True`` when *value* is a well-formed UUID string. 

225 

226 Args: 

227 value: String to test. 

228 

229 Returns: 

230 ``True`` if *value* parses as a UUID, ``False`` otherwise. 

231 """ 

232 try: 

233 uuid.UUID(value) 

234 return True 

235 except ValueError: 

236 return False 

237 

238 

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

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

241 

242 Args: 

243 result: Raw return value from ``DatabaseProviderProtocol.execute_query``. 

244 

245 Returns: 

246 Flat list of row-like objects (dicts or asyncpg ``Record`` instances). 

247 """ 

248 if hasattr(result, "rows"): 

249 return list(result.rows) 

250 if isinstance(result, list): 

251 return result 

252 return [] 

253 

254 

255def _row_to_event(row: Any) -> AdminSecurityEvent: 

256 """Construct an ``AdminSecurityEvent`` from a raw database row. 

257 

258 Args: 

259 row: Row-like mapping returned by the database driver. 

260 

261 Returns: 

262 Fully populated ``AdminSecurityEvent`` instance. 

263 """ 

264 raw_user_id = row["admin_user_id"] 

265 raw_metadata = row["metadata"] 

266 raw_created_at = row["created_at"] 

267 

268 # Coerce admin_user_id: UUID objects → str, None stays None. 

269 admin_user_id: str | None = str(raw_user_id) if raw_user_id is not None else None 

270 

271 # Deserialise metadata stored as a JSON string. 

272 metadata: dict[str, str | int | bool | None] = loads_str(raw_metadata or "{}") 

273 

274 # asyncpg returns timezone-aware datetimes; ensure we always have one. 

275 created_at: datetime = ( 

276 raw_created_at 

277 if isinstance(raw_created_at, datetime) 

278 else datetime.fromisoformat(str(raw_created_at)) 

279 ) 

280 

281 return AdminSecurityEvent( 

282 id=str(row["id"]), 

283 event_type=AdminSecurityEventType(row["event_type"]), 

284 admin_user_id=admin_user_id, 

285 ip_address=row["ip_address"], 

286 user_agent=row["user_agent"], 

287 success=bool(row["success"]), 

288 metadata=metadata, 

289 created_at=created_at, 

290 ) 

291 

292 

293__all__ = ["AdminAuditLogSqlStore"]