mdb-engine 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. mdb_engine/__init__.py +104 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +648 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +264 -69
  8. mdb_engine/auth/config_helpers.py +7 -6
  9. mdb_engine/auth/cookie_utils.py +3 -7
  10. mdb_engine/auth/csrf.py +373 -0
  11. mdb_engine/auth/decorators.py +3 -10
  12. mdb_engine/auth/dependencies.py +47 -50
  13. mdb_engine/auth/helpers.py +3 -3
  14. mdb_engine/auth/integration.py +53 -80
  15. mdb_engine/auth/jwt.py +2 -6
  16. mdb_engine/auth/middleware.py +77 -34
  17. mdb_engine/auth/oso_factory.py +18 -38
  18. mdb_engine/auth/provider.py +270 -171
  19. mdb_engine/auth/rate_limiter.py +504 -0
  20. mdb_engine/auth/restrictions.py +8 -24
  21. mdb_engine/auth/session_manager.py +14 -29
  22. mdb_engine/auth/shared_middleware.py +600 -0
  23. mdb_engine/auth/shared_users.py +759 -0
  24. mdb_engine/auth/token_store.py +14 -28
  25. mdb_engine/auth/users.py +54 -113
  26. mdb_engine/auth/utils.py +213 -15
  27. mdb_engine/cli/commands/generate.py +545 -9
  28. mdb_engine/cli/commands/validate.py +3 -7
  29. mdb_engine/cli/utils.py +3 -3
  30. mdb_engine/config.py +7 -21
  31. mdb_engine/constants.py +65 -0
  32. mdb_engine/core/README.md +117 -6
  33. mdb_engine/core/__init__.py +39 -7
  34. mdb_engine/core/app_registration.py +22 -41
  35. mdb_engine/core/app_secrets.py +290 -0
  36. mdb_engine/core/connection.py +18 -9
  37. mdb_engine/core/encryption.py +223 -0
  38. mdb_engine/core/engine.py +1057 -93
  39. mdb_engine/core/index_management.py +12 -16
  40. mdb_engine/core/manifest.py +459 -150
  41. mdb_engine/core/ray_integration.py +435 -0
  42. mdb_engine/core/seeding.py +10 -18
  43. mdb_engine/core/service_initialization.py +12 -23
  44. mdb_engine/core/types.py +2 -5
  45. mdb_engine/database/README.md +140 -17
  46. mdb_engine/database/__init__.py +17 -6
  47. mdb_engine/database/abstraction.py +25 -37
  48. mdb_engine/database/connection.py +11 -18
  49. mdb_engine/database/query_validator.py +367 -0
  50. mdb_engine/database/resource_limiter.py +204 -0
  51. mdb_engine/database/scoped_wrapper.py +713 -196
  52. mdb_engine/dependencies.py +426 -0
  53. mdb_engine/di/__init__.py +34 -0
  54. mdb_engine/di/container.py +248 -0
  55. mdb_engine/di/providers.py +205 -0
  56. mdb_engine/di/scopes.py +139 -0
  57. mdb_engine/embeddings/README.md +54 -24
  58. mdb_engine/embeddings/__init__.py +31 -24
  59. mdb_engine/embeddings/dependencies.py +37 -154
  60. mdb_engine/embeddings/service.py +11 -25
  61. mdb_engine/exceptions.py +92 -0
  62. mdb_engine/indexes/README.md +30 -13
  63. mdb_engine/indexes/__init__.py +1 -0
  64. mdb_engine/indexes/helpers.py +1 -1
  65. mdb_engine/indexes/manager.py +50 -114
  66. mdb_engine/memory/README.md +2 -2
  67. mdb_engine/memory/__init__.py +1 -2
  68. mdb_engine/memory/service.py +30 -87
  69. mdb_engine/observability/README.md +4 -2
  70. mdb_engine/observability/__init__.py +26 -9
  71. mdb_engine/observability/health.py +8 -9
  72. mdb_engine/observability/metrics.py +32 -12
  73. mdb_engine/repositories/__init__.py +34 -0
  74. mdb_engine/repositories/base.py +325 -0
  75. mdb_engine/repositories/mongo.py +233 -0
  76. mdb_engine/repositories/unit_of_work.py +166 -0
  77. mdb_engine/routing/README.md +1 -1
  78. mdb_engine/routing/__init__.py +1 -3
  79. mdb_engine/routing/websockets.py +25 -60
  80. mdb_engine-0.2.0.dist-info/METADATA +313 -0
  81. mdb_engine-0.2.0.dist-info/RECORD +96 -0
  82. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  83. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  84. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
  85. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
  86. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,592 @@
1
+ """
2
+ Authentication Audit Logging
3
+
4
+ Structured audit logging for authentication events. Provides forensics,
5
+ compliance support, and security monitoring capabilities.
6
+
7
+ This module is part of MDB_ENGINE - MongoDB Engine.
8
+
9
+ Features:
10
+ - Structured event logging to MongoDB
11
+ - TTL-based automatic cleanup
12
+ - Query helpers for security analysis
13
+ - Integration with shared auth middleware
14
+
15
+ Usage:
16
+ # Initialize audit logger
17
+ audit = AuthAuditLog(mongo_db)
18
+
19
+ # Log authentication events
20
+ await audit.log_event(
21
+ action=AuthAction.LOGIN_SUCCESS,
22
+ user_email="user@example.com",
23
+ success=True,
24
+ ip_address="192.168.1.1",
25
+ user_agent="Mozilla/5.0...",
26
+ )
27
+
28
+ # Query for security analysis
29
+ failed_logins = await audit.get_failed_logins(
30
+ email="user@example.com",
31
+ hours=24,
32
+ )
33
+ """
34
+
35
+ import logging
36
+ from datetime import datetime, timedelta
37
+ from enum import Enum
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ from motor.motor_asyncio import AsyncIOMotorDatabase
41
+ from pymongo.errors import OperationFailure
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Collection name for audit logs
46
+ AUDIT_COLLECTION = "_mdb_engine_auth_audit"
47
+
48
+ # Default retention period (90 days)
49
+ DEFAULT_RETENTION_DAYS = 90
50
+
51
+
52
+ class AuthAction(str, Enum):
53
+ """Authentication action types for audit logging."""
54
+
55
+ # Authentication events
56
+ LOGIN_SUCCESS = "login_success"
57
+ LOGIN_FAILED = "login_failed"
58
+ LOGOUT = "logout"
59
+ REGISTER = "register"
60
+
61
+ # Token events
62
+ TOKEN_REFRESHED = "token_refreshed"
63
+ TOKEN_REVOKED = "token_revoked"
64
+ TOKEN_EXPIRED = "token_expired"
65
+
66
+ # Account events
67
+ PASSWORD_CHANGED = "password_changed"
68
+ PASSWORD_RESET_REQUESTED = "password_reset_requested"
69
+ PASSWORD_RESET_COMPLETED = "password_reset_completed"
70
+
71
+ # Role/permission events
72
+ ROLE_GRANTED = "role_granted"
73
+ ROLE_REVOKED = "role_revoked"
74
+
75
+ # Security events
76
+ ACCOUNT_LOCKED = "account_locked"
77
+ ACCOUNT_UNLOCKED = "account_unlocked"
78
+ RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
79
+ SUSPICIOUS_ACTIVITY = "suspicious_activity"
80
+
81
+
82
+ class AuthAuditLog:
83
+ """
84
+ Manages structured audit logging for authentication events.
85
+
86
+ Logs are stored in MongoDB with TTL index for automatic cleanup.
87
+ Provides query helpers for security analysis and compliance.
88
+
89
+ Schema for audit documents:
90
+ {
91
+ "_id": ObjectId,
92
+ "timestamp": datetime,
93
+ "action": str (AuthAction value),
94
+ "success": bool,
95
+ "user_email": str | None,
96
+ "user_id": str | None,
97
+ "app_slug": str | None,
98
+ "ip_address": str | None,
99
+ "user_agent": str | None,
100
+ "details": dict | None,
101
+ "expires_at": datetime (for TTL)
102
+ }
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ mongo_db: AsyncIOMotorDatabase,
108
+ retention_days: int = DEFAULT_RETENTION_DAYS,
109
+ ):
110
+ """
111
+ Initialize the audit logger.
112
+
113
+ Args:
114
+ mongo_db: MongoDB database instance
115
+ retention_days: How long to keep audit logs (default: 90 days)
116
+ """
117
+ self._db = mongo_db
118
+ self._collection = mongo_db[AUDIT_COLLECTION]
119
+ self._retention_days = retention_days
120
+ self._indexes_created = False
121
+
122
+ logger.info(f"AuthAuditLog initialized (retention: {retention_days} days)")
123
+
124
+ async def ensure_indexes(self) -> None:
125
+ """Create necessary indexes for the audit collection."""
126
+ if self._indexes_created:
127
+ return
128
+
129
+ try:
130
+ # Index for querying by user
131
+ await self._collection.create_index(
132
+ [("user_email", 1), ("timestamp", -1)], name="user_email_timestamp_idx"
133
+ )
134
+
135
+ # Index for querying by action type
136
+ await self._collection.create_index(
137
+ [("action", 1), ("timestamp", -1)], name="action_timestamp_idx"
138
+ )
139
+
140
+ # Index for querying by IP address
141
+ await self._collection.create_index(
142
+ [("ip_address", 1), ("timestamp", -1)], name="ip_timestamp_idx"
143
+ )
144
+
145
+ # Index for querying by app
146
+ await self._collection.create_index(
147
+ [("app_slug", 1), ("timestamp", -1)], name="app_timestamp_idx"
148
+ )
149
+
150
+ # TTL index for automatic cleanup
151
+ await self._collection.create_index(
152
+ "expires_at", expireAfterSeconds=0, name="expires_at_ttl_idx"
153
+ )
154
+
155
+ self._indexes_created = True
156
+ logger.info("AuthAuditLog indexes ensured")
157
+ except OperationFailure as e:
158
+ logger.warning(f"Failed to create audit indexes: {e}")
159
+
160
+ async def log_event(
161
+ self,
162
+ action: AuthAction,
163
+ success: bool,
164
+ user_email: Optional[str] = None,
165
+ user_id: Optional[str] = None,
166
+ app_slug: Optional[str] = None,
167
+ ip_address: Optional[str] = None,
168
+ user_agent: Optional[str] = None,
169
+ details: Optional[Dict[str, Any]] = None,
170
+ ) -> str:
171
+ """
172
+ Log an authentication event.
173
+
174
+ Args:
175
+ action: Type of authentication action
176
+ success: Whether the action succeeded
177
+ user_email: User's email address
178
+ user_id: User's ID
179
+ app_slug: App where the action occurred
180
+ ip_address: Client IP address
181
+ user_agent: Client user agent string
182
+ details: Additional details (e.g., failure reason)
183
+
184
+ Returns:
185
+ Inserted document ID as string
186
+ """
187
+ await self.ensure_indexes()
188
+
189
+ now = datetime.utcnow()
190
+ expires_at = now + timedelta(days=self._retention_days)
191
+
192
+ doc = {
193
+ "timestamp": now,
194
+ "action": action.value if isinstance(action, AuthAction) else action,
195
+ "success": success,
196
+ "user_email": user_email,
197
+ "user_id": user_id,
198
+ "app_slug": app_slug,
199
+ "ip_address": ip_address,
200
+ "user_agent": user_agent,
201
+ "details": details,
202
+ "expires_at": expires_at,
203
+ }
204
+
205
+ result = await self._collection.insert_one(doc)
206
+
207
+ # Log to application logger as well
208
+ log_level = logging.INFO if success else logging.WARNING
209
+ logger.log(
210
+ log_level,
211
+ f"AUTH_AUDIT: {action.value if isinstance(action, AuthAction) else action} "
212
+ f"success={success} email={user_email} ip={ip_address} app={app_slug}",
213
+ )
214
+
215
+ return str(result.inserted_id)
216
+
217
+ async def log_login_success(
218
+ self,
219
+ email: str,
220
+ ip_address: Optional[str] = None,
221
+ user_agent: Optional[str] = None,
222
+ app_slug: Optional[str] = None,
223
+ ) -> str:
224
+ """Convenience method to log successful login."""
225
+ return await self.log_event(
226
+ action=AuthAction.LOGIN_SUCCESS,
227
+ success=True,
228
+ user_email=email,
229
+ ip_address=ip_address,
230
+ user_agent=user_agent,
231
+ app_slug=app_slug,
232
+ )
233
+
234
+ async def log_login_failed(
235
+ self,
236
+ email: str,
237
+ reason: str = "invalid_credentials",
238
+ ip_address: Optional[str] = None,
239
+ user_agent: Optional[str] = None,
240
+ app_slug: Optional[str] = None,
241
+ ) -> str:
242
+ """Convenience method to log failed login."""
243
+ return await self.log_event(
244
+ action=AuthAction.LOGIN_FAILED,
245
+ success=False,
246
+ user_email=email,
247
+ ip_address=ip_address,
248
+ user_agent=user_agent,
249
+ app_slug=app_slug,
250
+ details={"reason": reason},
251
+ )
252
+
253
+ async def log_logout(
254
+ self,
255
+ email: str,
256
+ ip_address: Optional[str] = None,
257
+ app_slug: Optional[str] = None,
258
+ ) -> str:
259
+ """Convenience method to log logout."""
260
+ return await self.log_event(
261
+ action=AuthAction.LOGOUT,
262
+ success=True,
263
+ user_email=email,
264
+ ip_address=ip_address,
265
+ app_slug=app_slug,
266
+ )
267
+
268
+ async def log_register(
269
+ self,
270
+ email: str,
271
+ ip_address: Optional[str] = None,
272
+ user_agent: Optional[str] = None,
273
+ app_slug: Optional[str] = None,
274
+ ) -> str:
275
+ """Convenience method to log new user registration."""
276
+ return await self.log_event(
277
+ action=AuthAction.REGISTER,
278
+ success=True,
279
+ user_email=email,
280
+ ip_address=ip_address,
281
+ user_agent=user_agent,
282
+ app_slug=app_slug,
283
+ )
284
+
285
+ async def log_role_change(
286
+ self,
287
+ email: str,
288
+ app_slug: str,
289
+ old_roles: List[str],
290
+ new_roles: List[str],
291
+ changed_by: Optional[str] = None,
292
+ ip_address: Optional[str] = None,
293
+ ) -> str:
294
+ """Log a role change event."""
295
+ action = (
296
+ AuthAction.ROLE_GRANTED if len(new_roles) > len(old_roles) else AuthAction.ROLE_REVOKED
297
+ )
298
+ return await self.log_event(
299
+ action=action,
300
+ success=True,
301
+ user_email=email,
302
+ app_slug=app_slug,
303
+ ip_address=ip_address,
304
+ details={
305
+ "old_roles": old_roles,
306
+ "new_roles": new_roles,
307
+ "changed_by": changed_by,
308
+ },
309
+ )
310
+
311
+ async def log_token_revoked(
312
+ self,
313
+ email: str,
314
+ reason: str = "logout",
315
+ ip_address: Optional[str] = None,
316
+ app_slug: Optional[str] = None,
317
+ ) -> str:
318
+ """Log token revocation."""
319
+ return await self.log_event(
320
+ action=AuthAction.TOKEN_REVOKED,
321
+ success=True,
322
+ user_email=email,
323
+ ip_address=ip_address,
324
+ app_slug=app_slug,
325
+ details={"reason": reason},
326
+ )
327
+
328
+ async def log_rate_limit_exceeded(
329
+ self,
330
+ ip_address: str,
331
+ endpoint: str,
332
+ email: Optional[str] = None,
333
+ app_slug: Optional[str] = None,
334
+ ) -> str:
335
+ """Log rate limit exceeded event."""
336
+ return await self.log_event(
337
+ action=AuthAction.RATE_LIMIT_EXCEEDED,
338
+ success=False,
339
+ user_email=email,
340
+ ip_address=ip_address,
341
+ app_slug=app_slug,
342
+ details={"endpoint": endpoint},
343
+ )
344
+
345
+ # ==========================================================================
346
+ # Query Methods for Security Analysis
347
+ # ==========================================================================
348
+
349
+ async def get_recent_events(
350
+ self,
351
+ hours: int = 24,
352
+ action: Optional[AuthAction] = None,
353
+ success: Optional[bool] = None,
354
+ limit: int = 100,
355
+ ) -> List[Dict[str, Any]]:
356
+ """
357
+ Get recent audit events.
358
+
359
+ Args:
360
+ hours: How many hours back to search
361
+ action: Filter by action type
362
+ success: Filter by success status
363
+ limit: Maximum results to return
364
+
365
+ Returns:
366
+ List of audit event documents
367
+ """
368
+ since = datetime.utcnow() - timedelta(hours=hours)
369
+
370
+ query: Dict[str, Any] = {"timestamp": {"$gte": since}}
371
+ if action:
372
+ query["action"] = action.value if isinstance(action, AuthAction) else action
373
+ if success is not None:
374
+ query["success"] = success
375
+
376
+ cursor = self._collection.find(query).sort("timestamp", -1).limit(limit)
377
+ events = await cursor.to_list(length=limit)
378
+
379
+ # Convert ObjectIds to strings
380
+ for event in events:
381
+ event["_id"] = str(event["_id"])
382
+
383
+ return events
384
+
385
+ async def get_failed_logins(
386
+ self,
387
+ email: Optional[str] = None,
388
+ ip_address: Optional[str] = None,
389
+ hours: int = 24,
390
+ limit: int = 100,
391
+ ) -> List[Dict[str, Any]]:
392
+ """
393
+ Get failed login attempts.
394
+
395
+ Args:
396
+ email: Filter by email
397
+ ip_address: Filter by IP address
398
+ hours: How many hours back to search
399
+ limit: Maximum results
400
+
401
+ Returns:
402
+ List of failed login events
403
+ """
404
+ since = datetime.utcnow() - timedelta(hours=hours)
405
+
406
+ query: Dict[str, Any] = {
407
+ "action": AuthAction.LOGIN_FAILED.value,
408
+ "timestamp": {"$gte": since},
409
+ }
410
+ if email:
411
+ query["user_email"] = email
412
+ if ip_address:
413
+ query["ip_address"] = ip_address
414
+
415
+ cursor = self._collection.find(query).sort("timestamp", -1).limit(limit)
416
+ events = await cursor.to_list(length=limit)
417
+
418
+ for event in events:
419
+ event["_id"] = str(event["_id"])
420
+
421
+ return events
422
+
423
+ async def get_user_activity(
424
+ self,
425
+ email: str,
426
+ hours: int = 168, # 7 days
427
+ limit: int = 100,
428
+ ) -> List[Dict[str, Any]]:
429
+ """
430
+ Get all activity for a specific user.
431
+
432
+ Args:
433
+ email: User email
434
+ hours: How many hours back to search
435
+ limit: Maximum results
436
+
437
+ Returns:
438
+ List of user's audit events
439
+ """
440
+ since = datetime.utcnow() - timedelta(hours=hours)
441
+
442
+ cursor = (
443
+ self._collection.find(
444
+ {
445
+ "user_email": email,
446
+ "timestamp": {"$gte": since},
447
+ }
448
+ )
449
+ .sort("timestamp", -1)
450
+ .limit(limit)
451
+ )
452
+
453
+ events = await cursor.to_list(length=limit)
454
+
455
+ for event in events:
456
+ event["_id"] = str(event["_id"])
457
+
458
+ return events
459
+
460
+ async def get_ip_activity(
461
+ self,
462
+ ip_address: str,
463
+ hours: int = 24,
464
+ limit: int = 100,
465
+ ) -> List[Dict[str, Any]]:
466
+ """
467
+ Get all activity from a specific IP address.
468
+
469
+ Useful for investigating suspicious activity.
470
+
471
+ Args:
472
+ ip_address: IP address to search
473
+ hours: How many hours back
474
+ limit: Maximum results
475
+
476
+ Returns:
477
+ List of audit events from that IP
478
+ """
479
+ since = datetime.utcnow() - timedelta(hours=hours)
480
+
481
+ cursor = (
482
+ self._collection.find(
483
+ {
484
+ "ip_address": ip_address,
485
+ "timestamp": {"$gte": since},
486
+ }
487
+ )
488
+ .sort("timestamp", -1)
489
+ .limit(limit)
490
+ )
491
+
492
+ events = await cursor.to_list(length=limit)
493
+
494
+ for event in events:
495
+ event["_id"] = str(event["_id"])
496
+
497
+ return events
498
+
499
+ async def count_failed_logins(
500
+ self,
501
+ email: Optional[str] = None,
502
+ ip_address: Optional[str] = None,
503
+ hours: int = 1,
504
+ ) -> int:
505
+ """
506
+ Count failed login attempts in a time window.
507
+
508
+ Useful for detecting brute-force attacks.
509
+
510
+ Args:
511
+ email: Filter by email
512
+ ip_address: Filter by IP
513
+ hours: Time window
514
+
515
+ Returns:
516
+ Number of failed login attempts
517
+ """
518
+ since = datetime.utcnow() - timedelta(hours=hours)
519
+
520
+ query: Dict[str, Any] = {
521
+ "action": AuthAction.LOGIN_FAILED.value,
522
+ "timestamp": {"$gte": since},
523
+ }
524
+ if email:
525
+ query["user_email"] = email
526
+ if ip_address:
527
+ query["ip_address"] = ip_address
528
+
529
+ return await self._collection.count_documents(query)
530
+
531
+ async def get_security_summary(
532
+ self,
533
+ hours: int = 24,
534
+ ) -> Dict[str, Any]:
535
+ """
536
+ Get security summary statistics.
537
+
538
+ Args:
539
+ hours: Time window for statistics
540
+
541
+ Returns:
542
+ Dict with security metrics
543
+ """
544
+ since = datetime.utcnow() - timedelta(hours=hours)
545
+
546
+ # Use aggregation for efficient counting
547
+ pipeline = [
548
+ {"$match": {"timestamp": {"$gte": since}}},
549
+ {
550
+ "$group": {
551
+ "_id": {"action": "$action", "success": "$success"},
552
+ "count": {"$sum": 1},
553
+ }
554
+ },
555
+ ]
556
+
557
+ cursor = self._collection.aggregate(pipeline)
558
+ results = await cursor.to_list(length=100)
559
+
560
+ # Build summary
561
+ summary = {
562
+ "period_hours": hours,
563
+ "total_events": 0,
564
+ "login_success": 0,
565
+ "login_failed": 0,
566
+ "registrations": 0,
567
+ "logouts": 0,
568
+ "tokens_revoked": 0,
569
+ "rate_limits_exceeded": 0,
570
+ }
571
+
572
+ for result in results:
573
+ action = result["_id"]["action"]
574
+ _success = result["_id"]["success"] # noqa: F841 - extracted for potential future use
575
+ count = result["count"]
576
+
577
+ summary["total_events"] += count
578
+
579
+ if action == AuthAction.LOGIN_SUCCESS.value:
580
+ summary["login_success"] = count
581
+ elif action == AuthAction.LOGIN_FAILED.value:
582
+ summary["login_failed"] = count
583
+ elif action == AuthAction.REGISTER.value:
584
+ summary["registrations"] = count
585
+ elif action == AuthAction.LOGOUT.value:
586
+ summary["logouts"] = count
587
+ elif action == AuthAction.TOKEN_REVOKED.value:
588
+ summary["tokens_revoked"] = count
589
+ elif action == AuthAction.RATE_LIMIT_EXCEEDED.value:
590
+ summary["rate_limits_exceeded"] = count
591
+
592
+ return summary