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
mdb_engine/auth/utils.py CHANGED
@@ -95,6 +95,145 @@ 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
239
  min_length: Optional[int] = None,
@@ -102,6 +241,8 @@ def validate_password_strength(
102
241
  require_lowercase: Optional[bool] = None,
103
242
  require_numbers: Optional[bool] = None,
104
243
  require_special: Optional[bool] = None,
244
+ min_entropy_bits: Optional[int] = None,
245
+ check_common_passwords: Optional[bool] = None,
105
246
  config: Optional[Dict[str, Any]] = None,
106
247
  ) -> Tuple[bool, List[str]]:
107
248
  """
@@ -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: Optional[Dict[str, Any]] = None,
342
+ check_breaches: Optional[bool] = 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.
@@ -478,9 +680,7 @@ async def register_user(
478
680
  )
479
681
 
480
682
  response = _create_registration_response(user_doc, redirect_url)
481
- set_auth_cookies(
482
- response, access_token, refresh_token, request=request, config=config
483
- )
683
+ set_auth_cookies(response, access_token, refresh_token, request=request, config=config)
484
684
 
485
685
  response.set_cookie(
486
686
  key="device_id",
@@ -510,9 +710,7 @@ async def register_user(
510
710
  return {"success": False, "error": "Registration failed. Please try again."}
511
711
 
512
712
 
513
- async def _get_user_id_from_request(
514
- request: Request, user_id: Optional[str]
515
- ) -> Optional[str]:
713
+ async def _get_user_id_from_request(request: Request, user_id: Optional[str]) -> Optional[str]:
516
714
  """Extract user_id from request if not provided."""
517
715
  if user_id:
518
716
  return user_id