Coverage for src / mcp_server_langgraph / compliance / gdpr / factory.py: 74%

43 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-03 00:43 +0000

1""" 

2GDPR Storage Factory 

3 

4Creates storage backend instances based on configuration. 

5Supports multiple backends: 

6- PostgreSQL (production-ready, recommended) 

7- In-memory (development/testing only) 

8 

9Pattern: Factory pattern with dependency injection 

10""" 

11 

12from typing import Any, Literal 

13 

14from mcp_server_langgraph.compliance.gdpr.postgres_storage import ( 

15 PostgresAuditLogStore, 

16 PostgresConsentStore, 

17 PostgresConversationStore, 

18 PostgresPreferencesStore, 

19 PostgresUserProfileStore, 

20) 

21from mcp_server_langgraph.compliance.gdpr.storage import ( 

22 AuditLogStore, 

23 ConsentStore, 

24 ConversationStore, 

25 InMemoryAuditLogStore, 

26 InMemoryConsentStore, 

27 InMemoryConversationStore, 

28 InMemoryPreferencesStore, 

29 InMemoryUserProfileStore, 

30 PreferencesStore, 

31 UserProfileStore, 

32) 

33 

34 

35class GDPRStorage: 

36 """ 

37 Container for all GDPR storage backends 

38 

39 Provides unified access to all storage components: 

40 - user_profiles: User profile storage 

41 - preferences: User preferences storage 

42 - consents: Consent record storage 

43 - conversations: Conversation storage 

44 - audit_logs: Audit log storage 

45 """ 

46 

47 def __init__( 

48 self, 

49 user_profiles: UserProfileStore, 

50 preferences: PreferencesStore, 

51 consents: ConsentStore, 

52 conversations: ConversationStore, 

53 audit_logs: AuditLogStore, 

54 ): 

55 self.user_profiles = user_profiles 

56 self.preferences = preferences 

57 self.consents = consents 

58 self.conversations = conversations 

59 self.audit_logs = audit_logs 

60 

61 

62async def create_postgres_storage(postgres_url: str) -> GDPRStorage: 

63 """ 

64 Create PostgreSQL-backed GDPR storage (RECOMMENDED for production) 

65 

66 Uses retry logic with exponential backoff to handle transient connection failures. 

67 

68 Args: 

69 postgres_url: PostgreSQL connection URL 

70 Example: postgresql://user:pass@localhost:5432/gdpr 

71 

72 Returns: 

73 GDPRStorage instance with PostgreSQL backends 

74 

75 Raises: 

76 asyncpg.PostgresError: If connection fails after retries 

77 Exception: If retries are exhausted 

78 

79 Example: 

80 >>> storage = await create_postgres_storage("postgresql://postgres:postgres@localhost:5432/gdpr") 

81 >>> profile = await storage.user_profiles.get("user:alice") 

82 """ 

83 from mcp_server_langgraph.infrastructure.database import create_connection_pool 

84 

85 # Create connection pool with retry logic 

86 # Retries: 3, Backoff: 1s, 2s, 4s (with jitter) 

87 pool = await create_connection_pool( 

88 postgres_url, 

89 min_size=2, 

90 max_size=10, 

91 command_timeout=60.0, 

92 max_retries=3, 

93 initial_delay=1.0, 

94 max_delay=8.0, 

95 ) 

96 

97 # Create storage instances 

98 return GDPRStorage( 

99 user_profiles=PostgresUserProfileStore(pool), 

100 preferences=PostgresPreferencesStore(pool), 

101 consents=PostgresConsentStore(pool), 

102 conversations=PostgresConversationStore(pool), 

103 audit_logs=PostgresAuditLogStore(pool), 

104 ) 

105 

106 

107def create_memory_storage() -> GDPRStorage: 

108 """ 

109 Create in-memory GDPR storage (DEVELOPMENT/TESTING ONLY) 

110 

111 WARNING: Data is lost when process restarts. 

112 NOT suitable for production use. 

113 

114 Returns: 

115 GDPRStorage instance with in-memory backends 

116 

117 Example: 

118 >>> storage = create_memory_storage() 

119 >>> # Use for testing only 

120 """ 

121 return GDPRStorage( 

122 user_profiles=InMemoryUserProfileStore(), 

123 preferences=InMemoryPreferencesStore(), 

124 consents=InMemoryConsentStore(), 

125 conversations=InMemoryConversationStore(), 

126 audit_logs=InMemoryAuditLogStore(), 

127 ) 

128 

129 

130async def create_gdpr_storage( 

131 backend: Literal["postgres", "memory"] = "postgres", 

132 postgres_url: str = "postgresql://postgres:postgres@localhost:5432/gdpr", 

133) -> GDPRStorage: 

134 """ 

135 Create GDPR storage with specified backend 

136 

137 Factory function that creates appropriate storage backend based on configuration. 

138 

139 Args: 

140 backend: Storage backend type ("postgres" or "memory") 

141 postgres_url: PostgreSQL connection URL (required if backend="postgres") 

142 

143 Returns: 

144 GDPRStorage instance 

145 

146 Raises: 

147 ValueError: If backend is invalid 

148 asyncpg.PostgresError: If PostgreSQL connection fails 

149 

150 Example: 

151 >>> # Production (PostgreSQL) 

152 >>> storage = await create_gdpr_storage( 

153 ... backend="postgres", 

154 ... postgres_url="postgresql://user:pass@db.example.com:5432/gdpr" 

155 ... ) 

156 

157 >>> # Development (in-memory) 

158 >>> storage = await create_gdpr_storage(backend="memory") 

159 """ 

160 if backend == "postgres": 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 return await create_postgres_storage(postgres_url) 

162 elif backend == "memory": 162 ↛ 165line 162 didn't jump to line 165 because the condition on line 162 was always true

163 return create_memory_storage() 

164 else: 

165 raise ValueError( # noqa: TRY003 

166 f"Invalid GDPR storage backend: {backend}. Must be 'postgres' or 'memory'." # noqa: EM102 

167 ) 

168 

169 

170# ============================================================================ 

171# Global Singleton Pattern (DEPRECATED - for backward compatibility only) 

172# ============================================================================ 

173# WARNING: Global singleton causes pytest-xdist cross-worker pollution 

174# NEW CODE: Use get_gdpr_storage_dependency() for request-scoped storage 

175# See: CODEX_FINDINGS_VALIDATION_REPORT_2025-11-21.md 

176# ============================================================================ 

177 

178_gdpr_storage: GDPRStorage | None = None 

179 

180 

181async def initialize_gdpr_storage( 

182 backend: Literal["postgres", "memory"] = "postgres", 

183 postgres_url: str = "postgresql://postgres:postgres@localhost:5432/gdpr", 

184) -> None: 

185 """ 

186 Initialize global GDPR storage instance 

187 

188 .. deprecated:: 2025-11-22 

189 Use get_gdpr_storage_dependency() for request-scoped storage instead. 

190 This function will be removed in v2.0. 

191 

192 Should be called during application startup. 

193 

194 Args: 

195 backend: Storage backend type 

196 postgres_url: PostgreSQL connection URL 

197 

198 Example: 

199 >>> # DEPRECATED - Old pattern 

200 >>> await initialize_gdpr_storage( 

201 ... backend=settings.gdpr_storage_backend, 

202 ... postgres_url=settings.gdpr_postgres_url 

203 ... ) 

204 >>> 

205 >>> # NEW pattern - Request-scoped dependency injection 

206 >>> # No initialization needed - storage created per-request 

207 """ 

208 global _gdpr_storage 

209 _gdpr_storage = await create_gdpr_storage(backend, postgres_url) 

210 

211 

212def get_gdpr_storage() -> GDPRStorage: 

213 """ 

214 Get global GDPR storage instance 

215 

216 .. deprecated:: 2025-11-22 

217 Use get_gdpr_storage_dependency() for request-scoped storage instead. 

218 This function will be removed in v2.0. 

219 

220 Returns: 

221 GDPRStorage instance 

222 

223 Raises: 

224 RuntimeError: If storage not initialized 

225 

226 Example: 

227 >>> # DEPRECATED - Old pattern 

228 >>> storage = get_gdpr_storage() 

229 >>> profile = await storage.user_profiles.get("user:alice") 

230 >>> 

231 >>> # NEW pattern - FastAPI dependency injection 

232 >>> from fastapi import Depends 

233 >>> def my_endpoint(storage: GDPRStorage = Depends(get_gdpr_storage_dependency)): 

234 ... profile = await storage.user_profiles.get("user:alice") 

235 """ 

236 if _gdpr_storage is None: 

237 msg = "GDPR storage not initialized. Call initialize_gdpr_storage() during application startup." 

238 raise RuntimeError(msg) 

239 return _gdpr_storage 

240 

241 

242def reset_gdpr_storage() -> None: 

243 """ 

244 Reset global GDPR storage instance 

245 

246 .. deprecated:: 2025-11-22 

247 Not needed with request-scoped storage - each request gets fresh instance. 

248 This function will be removed in v2.0. 

249 

250 Used for testing to reset storage between tests. 

251 

252 Example: 

253 >>> # DEPRECATED - Old pattern 

254 >>> reset_gdpr_storage() 

255 >>> 

256 >>> # NEW pattern - No reset needed (request-scoped) 

257 >>> # Each test request gets isolated storage instance 

258 """ 

259 global _gdpr_storage 

260 _gdpr_storage = None 

261 

262 

263# ============================================================================ 

264# Request-Scoped Dependency Injection (RECOMMENDED) 

265# ============================================================================ 

266# Benefits: 

267# - No global state - eliminates pytest-xdist cross-worker pollution 

268# - Better testability - each request gets isolated storage 

269# - Thread-safe - no shared mutable state 

270# - Cloud-native - works with serverless/multi-instance deployments 

271# ============================================================================ 

272 

273# Configuration for request-scoped storage (set during app startup) 

274_gdpr_storage_config: dict[str, Any] | None = None 

275 

276 

277def configure_gdpr_storage( 

278 backend: Literal["postgres", "memory"] = "postgres", 

279 postgres_url: str = "postgresql://postgres:postgres@localhost:5432/gdpr", 

280) -> None: 

281 """ 

282 Configure GDPR storage for request-scoped dependency injection 

283 

284 Call this during application startup to set configuration. 

285 Unlike initialize_gdpr_storage(), this does NOT create storage immediately. 

286 Storage is created per-request using get_gdpr_storage_dependency(). 

287 

288 Args: 

289 backend: Storage backend type ("postgres" or "memory") 

290 postgres_url: PostgreSQL connection URL 

291 

292 Example: 

293 >>> # In main.py application startup 

294 >>> configure_gdpr_storage( 

295 ... backend="postgres", 

296 ... postgres_url="postgresql://user:pass@localhost:5432/gdpr" 

297 ... ) 

298 >>> 

299 >>> # In API endpoints (automatic via dependency injection) 

300 >>> @router.get("/users/me") 

301 >>> async def get_user(storage: GDPRStorage = Depends(get_gdpr_storage_dependency)): 

302 ... return await storage.user_profiles.get("user:alice") 

303 """ 

304 global _gdpr_storage_config 

305 _gdpr_storage_config = { 

306 "backend": backend, 

307 "postgres_url": postgres_url, 

308 } 

309 

310 

311async def get_gdpr_storage_dependency() -> GDPRStorage: 

312 """ 

313 FastAPI dependency for request-scoped GDPR storage 

314 

315 Creates a new storage instance for each request using configured settings. 

316 No global state - eliminates pytest-xdist cross-worker pollution. 

317 

318 Returns: 

319 GDPRStorage instance (fresh per request) 

320 

321 Raises: 

322 RuntimeError: If storage not configured (call configure_gdpr_storage() first) 

323 

324 Example: 

325 >>> from fastapi import Depends, APIRouter 

326 >>> router = APIRouter() 

327 >>> 

328 >>> @router.get("/users/{user_id}") 

329 >>> async def get_user( 

330 ... user_id: str, 

331 ... storage: GDPRStorage = Depends(get_gdpr_storage_dependency) 

332 >>> ): 

333 ... return await storage.user_profiles.get(user_id) 

334 """ 

335 if _gdpr_storage_config is None: 335 ↛ 345line 335 didn't jump to line 345 because the condition on line 335 was always true

336 # Fallback to global singleton for backward compatibility 

337 # This allows tests to work without migration 

338 if _gdpr_storage is not None: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 return _gdpr_storage 

340 

341 # If neither is configured, use default memory storage for development 

342 return create_memory_storage() 

343 

344 # Create fresh storage instance per request 

345 backend = _gdpr_storage_config.get("backend", "memory") 

346 postgres_url = _gdpr_storage_config.get("postgres_url", "postgresql://postgres:postgres@localhost:5432/gdpr") 

347 

348 return await create_gdpr_storage(backend=backend, postgres_url=postgres_url)