mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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 (92) hide show
  1. mdb_engine/__init__.py +116 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +654 -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 +265 -70
  8. mdb_engine/auth/config_defaults.py +5 -5
  9. mdb_engine/auth/config_helpers.py +19 -18
  10. mdb_engine/auth/cookie_utils.py +12 -16
  11. mdb_engine/auth/csrf.py +483 -0
  12. mdb_engine/auth/decorators.py +10 -16
  13. mdb_engine/auth/dependencies.py +69 -71
  14. mdb_engine/auth/helpers.py +3 -3
  15. mdb_engine/auth/integration.py +61 -88
  16. mdb_engine/auth/jwt.py +11 -15
  17. mdb_engine/auth/middleware.py +79 -35
  18. mdb_engine/auth/oso_factory.py +21 -41
  19. mdb_engine/auth/provider.py +270 -171
  20. mdb_engine/auth/rate_limiter.py +505 -0
  21. mdb_engine/auth/restrictions.py +21 -36
  22. mdb_engine/auth/session_manager.py +24 -41
  23. mdb_engine/auth/shared_middleware.py +977 -0
  24. mdb_engine/auth/shared_users.py +775 -0
  25. mdb_engine/auth/token_lifecycle.py +10 -12
  26. mdb_engine/auth/token_store.py +17 -32
  27. mdb_engine/auth/users.py +99 -159
  28. mdb_engine/auth/utils.py +236 -42
  29. mdb_engine/cli/commands/generate.py +546 -10
  30. mdb_engine/cli/commands/validate.py +3 -7
  31. mdb_engine/cli/utils.py +7 -7
  32. mdb_engine/config.py +13 -28
  33. mdb_engine/constants.py +65 -0
  34. mdb_engine/core/README.md +117 -6
  35. mdb_engine/core/__init__.py +39 -7
  36. mdb_engine/core/app_registration.py +31 -50
  37. mdb_engine/core/app_secrets.py +289 -0
  38. mdb_engine/core/connection.py +20 -12
  39. mdb_engine/core/encryption.py +222 -0
  40. mdb_engine/core/engine.py +2862 -115
  41. mdb_engine/core/index_management.py +12 -16
  42. mdb_engine/core/manifest.py +628 -204
  43. mdb_engine/core/ray_integration.py +436 -0
  44. mdb_engine/core/seeding.py +13 -21
  45. mdb_engine/core/service_initialization.py +20 -30
  46. mdb_engine/core/types.py +40 -43
  47. mdb_engine/database/README.md +140 -17
  48. mdb_engine/database/__init__.py +17 -6
  49. mdb_engine/database/abstraction.py +37 -50
  50. mdb_engine/database/connection.py +51 -30
  51. mdb_engine/database/query_validator.py +367 -0
  52. mdb_engine/database/resource_limiter.py +204 -0
  53. mdb_engine/database/scoped_wrapper.py +747 -237
  54. mdb_engine/dependencies.py +427 -0
  55. mdb_engine/di/__init__.py +34 -0
  56. mdb_engine/di/container.py +247 -0
  57. mdb_engine/di/providers.py +206 -0
  58. mdb_engine/di/scopes.py +139 -0
  59. mdb_engine/embeddings/README.md +54 -24
  60. mdb_engine/embeddings/__init__.py +31 -24
  61. mdb_engine/embeddings/dependencies.py +38 -155
  62. mdb_engine/embeddings/service.py +78 -75
  63. mdb_engine/exceptions.py +104 -12
  64. mdb_engine/indexes/README.md +30 -13
  65. mdb_engine/indexes/__init__.py +1 -0
  66. mdb_engine/indexes/helpers.py +11 -11
  67. mdb_engine/indexes/manager.py +59 -123
  68. mdb_engine/memory/README.md +95 -4
  69. mdb_engine/memory/__init__.py +1 -2
  70. mdb_engine/memory/service.py +363 -1168
  71. mdb_engine/observability/README.md +4 -2
  72. mdb_engine/observability/__init__.py +26 -9
  73. mdb_engine/observability/health.py +17 -17
  74. mdb_engine/observability/logging.py +10 -10
  75. mdb_engine/observability/metrics.py +40 -19
  76. mdb_engine/repositories/__init__.py +34 -0
  77. mdb_engine/repositories/base.py +325 -0
  78. mdb_engine/repositories/mongo.py +233 -0
  79. mdb_engine/repositories/unit_of_work.py +166 -0
  80. mdb_engine/routing/README.md +1 -1
  81. mdb_engine/routing/__init__.py +1 -3
  82. mdb_engine/routing/websockets.py +41 -75
  83. mdb_engine/utils/__init__.py +3 -1
  84. mdb_engine/utils/mongo.py +117 -0
  85. mdb_engine-0.4.12.dist-info/METADATA +492 -0
  86. mdb_engine-0.4.12.dist-info/RECORD +97 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
  88. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  89. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  90. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
  91. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
  92. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
mdb_engine/auth/utils.py CHANGED
@@ -11,7 +11,7 @@ import logging
11
11
  import re
12
12
  import uuid
13
13
  from datetime import datetime
14
- from typing import Any, Dict, List, Optional, Tuple
14
+ from typing import Any
15
15
 
16
16
  import bcrypt
17
17
  from fastapi import Request, Response
@@ -43,7 +43,7 @@ def _detect_browser(user_agent: str) -> str:
43
43
  return "unknown"
44
44
 
45
45
 
46
- def _detect_os_and_device_type(user_agent: str) -> Tuple[str, str]:
46
+ def _detect_os_and_device_type(user_agent: str) -> tuple[str, str]:
47
47
  """Detect OS and device type from user agent string."""
48
48
  if not user_agent:
49
49
  return "unknown", "desktop"
@@ -64,7 +64,7 @@ def _detect_os_and_device_type(user_agent: str) -> Tuple[str, str]:
64
64
  return "unknown", "desktop"
65
65
 
66
66
 
67
- def get_device_info(request: Request) -> Dict[str, Any]:
67
+ def get_device_info(request: Request) -> dict[str, Any]:
68
68
  """
69
69
  Extract device information from request.
70
70
 
@@ -95,15 +95,156 @@ def get_device_info(request: Request) -> Dict[str, Any]:
95
95
  }
96
96
 
97
97
 
98
+ def calculate_password_entropy(password: str) -> float:
99
+ """
100
+ Calculate the entropy of a password in bits.
101
+
102
+ Entropy measures password randomness/unpredictability.
103
+ Higher entropy = more secure password.
104
+
105
+ Character set sizes:
106
+ - Lowercase: 26
107
+ - Uppercase: 26
108
+ - Digits: 10
109
+ - Special: ~32
110
+
111
+ Formula: entropy = length * log2(character_set_size)
112
+
113
+ Args:
114
+ password: Password to calculate entropy for
115
+
116
+ Returns:
117
+ Entropy in bits (float)
118
+ """
119
+ import math
120
+
121
+ if not password:
122
+ return 0.0
123
+
124
+ # Determine character set size based on what's used
125
+ char_set_size = 0
126
+
127
+ if re.search(r"[a-z]", password):
128
+ char_set_size += 26
129
+ if re.search(r"[A-Z]", password):
130
+ char_set_size += 26
131
+ if re.search(r"\d", password):
132
+ char_set_size += 10
133
+ if re.search(r'[!@#$%^&*(),.?":{}|<>\[\]\\;\'`~_+=\-/]', password):
134
+ char_set_size += 32
135
+ if re.search(r"\s", password):
136
+ char_set_size += 1
137
+
138
+ if char_set_size == 0:
139
+ # Fallback for Unicode or other characters
140
+ char_set_size = 94 # Printable ASCII assumption
141
+
142
+ # Entropy = length * log2(char_set_size)
143
+ entropy = len(password) * math.log2(char_set_size)
144
+
145
+ return round(entropy, 2)
146
+
147
+
148
+ def is_common_password(password: str) -> bool:
149
+ """
150
+ Check if password is in the common passwords list.
151
+
152
+ Uses a bundled list of the top 10,000 most common passwords.
153
+ Falls back gracefully if the file is not available.
154
+
155
+ Args:
156
+ password: Password to check
157
+
158
+ Returns:
159
+ True if password is common, False otherwise
160
+ """
161
+ try:
162
+ import os
163
+
164
+ # Get the path to the common passwords file
165
+ resources_dir = os.path.join(os.path.dirname(__file__), "resources")
166
+ common_passwords_path = os.path.join(resources_dir, "common_passwords.txt")
167
+
168
+ if not os.path.exists(common_passwords_path):
169
+ logger.debug("Common passwords file not found, skipping check")
170
+ return False
171
+
172
+ # Read and check (case-insensitive)
173
+ password_lower = password.lower()
174
+ with open(common_passwords_path, encoding="utf-8") as f:
175
+ for line in f:
176
+ if line.strip().lower() == password_lower:
177
+ return True
178
+
179
+ return False
180
+ except OSError as e:
181
+ logger.warning(f"Error checking common passwords: {e}")
182
+ return False
183
+
184
+
185
+ async def check_password_breach(password: str) -> bool:
186
+ """
187
+ Check if password has been exposed in known data breaches.
188
+
189
+ Uses the HaveIBeenPwned API with k-anonymity (only sends first 5 chars of hash).
190
+ This is privacy-preserving - the full password hash is never sent.
191
+
192
+ Requires network access. Returns False (not breached) if check fails.
193
+
194
+ Args:
195
+ password: Password to check
196
+
197
+ Returns:
198
+ True if password was found in breaches, False otherwise
199
+ """
200
+ try:
201
+ import hashlib
202
+
203
+ import httpx
204
+
205
+ # Hash the password
206
+ sha1_hash = hashlib.sha1(password.encode()).hexdigest().upper()
207
+ prefix = sha1_hash[:5]
208
+ suffix = sha1_hash[5:]
209
+
210
+ # Query HIBP API (k-anonymity: only send first 5 chars)
211
+ url = f"https://api.pwnedpasswords.com/range/{prefix}"
212
+
213
+ async with httpx.AsyncClient(timeout=5.0) as client:
214
+ response = await client.get(url)
215
+ response.raise_for_status()
216
+
217
+ # Check if our suffix is in the response
218
+ for line in response.text.splitlines():
219
+ hash_suffix, count = line.split(":")
220
+ if hash_suffix == suffix:
221
+ logger.debug(f"Password found in {count} breaches")
222
+ return True
223
+
224
+ return False
225
+
226
+ except ImportError:
227
+ logger.debug("httpx not available, skipping breach check")
228
+ return False
229
+ except httpx.HTTPError as e:
230
+ logger.warning(f"Error checking password breaches: {e}")
231
+ return False
232
+ except (TimeoutError, ConnectionError, OSError) as e:
233
+ logger.warning(f"Error checking password breaches: {e}")
234
+ return False
235
+
236
+
98
237
  def validate_password_strength(
99
238
  password: str,
100
- min_length: Optional[int] = None,
101
- require_uppercase: Optional[bool] = None,
102
- require_lowercase: Optional[bool] = None,
103
- require_numbers: Optional[bool] = None,
104
- require_special: Optional[bool] = None,
105
- config: Optional[Dict[str, Any]] = None,
106
- ) -> Tuple[bool, List[str]]:
239
+ min_length: int | None = None,
240
+ require_uppercase: bool | None = None,
241
+ require_lowercase: bool | None = None,
242
+ require_numbers: bool | None = None,
243
+ require_special: bool | None = None,
244
+ min_entropy_bits: int | None = None,
245
+ check_common_passwords: bool | None = None,
246
+ config: dict[str, Any] | None = None,
247
+ ) -> tuple[bool, list[str]]:
107
248
  """
108
249
  Validate password strength with configurable rules.
109
250
 
@@ -114,6 +255,8 @@ def validate_password_strength(
114
255
  require_lowercase: Require lowercase letters (default: from config or True)
115
256
  require_numbers: Require numbers (default: from config or True)
116
257
  require_special: Require special characters (default: from config or False)
258
+ min_entropy_bits: Minimum entropy in bits (default: from config or 0)
259
+ check_common_passwords: Check against common passwords (default: from config or False)
117
260
  config: Optional password_policy config dict from manifest
118
261
 
119
262
  Returns:
@@ -125,9 +268,7 @@ def validate_password_strength(
125
268
  return False, ["Password is required"]
126
269
 
127
270
  if config:
128
- min_length = (
129
- min_length if min_length is not None else config.get("min_length", 8)
130
- )
271
+ min_length = min_length if min_length is not None else config.get("min_length", 8)
131
272
  require_uppercase = (
132
273
  require_uppercase
133
274
  if require_uppercase is not None
@@ -139,14 +280,18 @@ def validate_password_strength(
139
280
  else config.get("require_lowercase", True)
140
281
  )
141
282
  require_numbers = (
142
- require_numbers
143
- if require_numbers is not None
144
- else config.get("require_numbers", True)
283
+ require_numbers if require_numbers is not None else config.get("require_numbers", True)
145
284
  )
146
285
  require_special = (
147
- require_special
148
- if require_special is not None
149
- else config.get("require_special", False)
286
+ require_special if require_special is not None else config.get("require_special", False)
287
+ )
288
+ min_entropy_bits = (
289
+ min_entropy_bits if min_entropy_bits is not None else config.get("min_entropy_bits", 0)
290
+ )
291
+ check_common_passwords = (
292
+ check_common_passwords
293
+ if check_common_passwords is not None
294
+ else config.get("check_common_passwords", False)
150
295
  )
151
296
  else:
152
297
  min_length = min_length if min_length is not None else 8
@@ -154,6 +299,10 @@ def validate_password_strength(
154
299
  require_lowercase = require_lowercase if require_lowercase is not None else True
155
300
  require_numbers = require_numbers if require_numbers is not None else True
156
301
  require_special = require_special if require_special is not None else False
302
+ min_entropy_bits = min_entropy_bits if min_entropy_bits is not None else 0
303
+ check_common_passwords = (
304
+ check_common_passwords if check_common_passwords is not None else False
305
+ )
157
306
 
158
307
  if len(password) < min_length:
159
308
  errors.append(f"Password must be at least {min_length} characters long")
@@ -170,9 +319,62 @@ def validate_password_strength(
170
319
  if require_special and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
171
320
  errors.append("Password must contain at least one special character")
172
321
 
322
+ # Entropy check
323
+ if min_entropy_bits and min_entropy_bits > 0:
324
+ entropy = calculate_password_entropy(password)
325
+ if entropy < min_entropy_bits:
326
+ errors.append(
327
+ f"Password is too weak (entropy: {entropy:.0f} bits, "
328
+ f"required: {min_entropy_bits} bits)"
329
+ )
330
+
331
+ # Common password check
332
+ if check_common_passwords:
333
+ if is_common_password(password):
334
+ errors.append("Password is too common - please choose a unique password")
335
+
173
336
  return len(errors) == 0, errors
174
337
 
175
338
 
339
+ async def validate_password_strength_async(
340
+ password: str,
341
+ config: dict[str, Any] | None = None,
342
+ check_breaches: bool | None = None,
343
+ ) -> tuple[bool, list[str]]:
344
+ """
345
+ Async version of validate_password_strength with breach checking.
346
+
347
+ This performs all synchronous checks, plus an optional async breach
348
+ check against HaveIBeenPwned.
349
+
350
+ Args:
351
+ password: Password to validate
352
+ config: Optional password_policy config dict from manifest
353
+ check_breaches: Check against HIBP breach database (default: from config or False)
354
+
355
+ Returns:
356
+ Tuple of (is_valid, list_of_errors)
357
+ """
358
+ # First, run sync validation
359
+ is_valid, errors = validate_password_strength(password, config=config)
360
+
361
+ # Determine if we should check breaches
362
+ if check_breaches is None and config:
363
+ check_breaches = config.get("check_breaches", False)
364
+ elif check_breaches is None:
365
+ check_breaches = False
366
+
367
+ # Async breach check
368
+ if check_breaches:
369
+ if await check_password_breach(password):
370
+ errors.append(
371
+ "Password has been exposed in a data breach - " "please choose a different password"
372
+ )
373
+ is_valid = False
374
+
375
+ return is_valid, errors
376
+
377
+
176
378
  def generate_session_fingerprint(request: Request, device_id: str) -> str:
177
379
  """
178
380
  Generate a session fingerprint from request characteristics.
@@ -202,10 +404,10 @@ async def login_user(
202
404
  email: str,
203
405
  password: str,
204
406
  db,
205
- config: Optional[Dict[str, Any]] = None,
407
+ config: dict[str, Any] | None = None,
206
408
  remember_me: bool = False,
207
- redirect_url: Optional[str] = None,
208
- ) -> Dict[str, Any]:
409
+ redirect_url: str | None = None,
410
+ ) -> dict[str, Any]:
209
411
  """
210
412
  Handle user login with automatic token generation and cookie setting.
211
413
 
@@ -369,8 +571,8 @@ def _validate_email_format(email: str) -> bool:
369
571
 
370
572
 
371
573
  def _get_password_policy_from_config(
372
- request: Request, config: Optional[Dict[str, Any]]
373
- ) -> Optional[Dict[str, Any]]:
574
+ request: Request, config: dict[str, Any] | None
575
+ ) -> dict[str, Any] | None:
374
576
  """Get password policy from config or request."""
375
577
  if config:
376
578
  security = config.get("security", {})
@@ -383,8 +585,8 @@ def _get_password_policy_from_config(
383
585
 
384
586
 
385
587
  async def _create_user_document(
386
- email: str, password: str, extra_data: Optional[Dict[str, Any]]
387
- ) -> Dict[str, Any]:
588
+ email: str, password: str, extra_data: dict[str, Any] | None
589
+ ) -> dict[str, Any]:
388
590
  """Create user document with hashed password."""
389
591
  password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
390
592
  user_doc = {
@@ -398,9 +600,7 @@ async def _create_user_document(
398
600
  return user_doc
399
601
 
400
602
 
401
- def _create_registration_response(
402
- user_doc: Dict[str, Any], redirect_url: Optional[str]
403
- ) -> Response:
603
+ def _create_registration_response(user_doc: dict[str, Any], redirect_url: str | None) -> Response:
404
604
  """Create response for registration."""
405
605
  if redirect_url:
406
606
  return RedirectResponse(url=redirect_url, status_code=302)
@@ -420,10 +620,10 @@ async def register_user(
420
620
  email: str,
421
621
  password: str,
422
622
  db,
423
- config: Optional[Dict[str, Any]] = None,
424
- extra_data: Optional[Dict[str, Any]] = None,
425
- redirect_url: Optional[str] = None,
426
- ) -> Dict[str, Any]:
623
+ config: dict[str, Any] | None = None,
624
+ extra_data: dict[str, Any] | None = None,
625
+ redirect_url: str | None = None,
626
+ ) -> dict[str, Any]:
427
627
  """
428
628
  Handle user registration with automatic token generation.
429
629
 
@@ -478,9 +678,7 @@ async def register_user(
478
678
  )
479
679
 
480
680
  response = _create_registration_response(user_doc, redirect_url)
481
- set_auth_cookies(
482
- response, access_token, refresh_token, request=request, config=config
483
- )
681
+ set_auth_cookies(response, access_token, refresh_token, request=request, config=config)
484
682
 
485
683
  response.set_cookie(
486
684
  key="device_id",
@@ -510,9 +708,7 @@ async def register_user(
510
708
  return {"success": False, "error": "Registration failed. Please try again."}
511
709
 
512
710
 
513
- async def _get_user_id_from_request(
514
- request: Request, user_id: Optional[str]
515
- ) -> Optional[str]:
711
+ async def _get_user_id_from_request(request: Request, user_id: str | None) -> str | None:
516
712
  """Extract user_id from request if not provided."""
517
713
  if user_id:
518
714
  return user_id
@@ -575,9 +771,7 @@ async def _revoke_session(request: Request) -> None:
575
771
  await session_mgr.revoke_session_by_refresh_token(refresh_jti)
576
772
 
577
773
 
578
- async def logout_user(
579
- request: Request, response: Response, user_id: Optional[str] = None
580
- ) -> Response:
774
+ async def logout_user(request: Request, response: Response, user_id: str | None = None) -> Response:
581
775
  """
582
776
  Handle user logout with token revocation and cookie clearing.
583
777