mdb-engine 0.4.1__py3-none-any.whl → 0.4.2__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.
mdb_engine/__init__.py CHANGED
@@ -81,7 +81,10 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
81
81
  # Utilities
82
82
  from .utils import clean_mongo_doc, clean_mongo_docs
83
83
 
84
- __version__ = "0.4.1" # Patch version: Bug fixes for path prefix handling and test improvements
84
+ __version__ = (
85
+ "0.4.2" # Security release: Fixed auto-role assignment, token blacklist
86
+ # fail-closed, race conditions, session binding, and path traversal
87
+ )
85
88
 
86
89
  __all__ = [
87
90
  # Core Engine
@@ -92,7 +92,10 @@ def _get_request_path(request: Request) -> str:
92
92
 
93
93
  This ensures public routes in manifests (which are relative paths like "/")
94
94
  match correctly when apps are mounted at prefixes like "/auth-hub".
95
+
96
+ SECURITY: Normalizes and validates paths to prevent path traversal attacks.
95
97
  """
98
+
96
99
  # Check if this is a mounted app with a path prefix
97
100
  app_base_path = getattr(request.state, "app_base_path", None)
98
101
  # Ensure app_base_path is a string (not a MagicMock in tests)
@@ -102,21 +105,78 @@ def _get_request_path(request: Request) -> str:
102
105
  if url_path and url_path.startswith(app_base_path):
103
106
  # Strip the path prefix to get relative path
104
107
  relative_path = url_path[len(app_base_path) :]
108
+ # Normalize and sanitize path to prevent traversal attacks
109
+ relative_path = _normalize_path(relative_path)
105
110
  # Ensure path starts with / (handle case where prefix is entire path)
106
111
  return relative_path if relative_path else "/"
107
112
 
108
113
  # Fall back to scope["path"] for mounted apps (if available)
109
114
  # This handles cases where Starlette/FastAPI sets it correctly
110
115
  if "path" in request.scope:
111
- return request.scope["path"]
116
+ return _normalize_path(request.scope["path"])
112
117
 
113
118
  # Default to url.path for non-mounted apps
114
119
  # Ensure we return a string
115
120
  if hasattr(request.url, "path"):
116
- return str(request.url.path)
121
+ return _normalize_path(str(request.url.path))
117
122
  return "/"
118
123
 
119
124
 
125
+ def _normalize_path(path: str) -> str:
126
+ """
127
+ Normalize and sanitize a path to prevent path traversal attacks.
128
+
129
+ Args:
130
+ path: Raw path string
131
+
132
+ Returns:
133
+ Normalized path starting with /
134
+ """
135
+ from pathlib import PurePath
136
+ from urllib.parse import unquote
137
+
138
+ if not path:
139
+ return "/"
140
+
141
+ # Preserve trailing slash (except for root)
142
+ has_trailing_slash = path.endswith("/") and path != "/"
143
+
144
+ # Decode URL encoding
145
+ try:
146
+ decoded = unquote(path)
147
+ except (ValueError, UnicodeDecodeError):
148
+ # If decoding fails, use original path
149
+ decoded = path
150
+
151
+ # Normalize path separators and resolve relative components
152
+ try:
153
+ # Use PurePath to normalize without accessing filesystem
154
+ normalized = PurePath(decoded).as_posix()
155
+ except (ValueError, TypeError):
156
+ # If normalization fails, use decoded path
157
+ normalized = decoded
158
+
159
+ # Reject path traversal attempts
160
+ if ".." in normalized or normalized.startswith("/") and normalized != "/":
161
+ # Check if it's a legitimate absolute path (starts with /)
162
+ if normalized.startswith("/") and ".." not in normalized:
163
+ # Valid absolute path
164
+ pass
165
+ else:
166
+ logger.warning(f"Path traversal attempt detected: {path} -> {normalized}")
167
+ return "/" # Return root path for safety
168
+
169
+ # Ensure path starts with /
170
+ if not normalized.startswith("/"):
171
+ normalized = "/" + normalized
172
+
173
+ # Restore trailing slash if it was present (except for root)
174
+ if has_trailing_slash and normalized != "/" and not normalized.endswith("/"):
175
+ normalized = normalized + "/"
176
+
177
+ return normalized
178
+
179
+
120
180
  class SharedAuthMiddleware(BaseHTTPMiddleware):
121
181
  """
122
182
  Middleware for shared authentication across multi-app deployments.
@@ -142,6 +202,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
142
202
  public_routes: list[str] | None = None,
143
203
  role_hierarchy: dict[str, list[str]] | None = None,
144
204
  session_binding: dict[str, Any] | None = None,
205
+ auto_assign_default_role: bool = False,
145
206
  cookie_name: str = AUTH_COOKIE_NAME,
146
207
  header_name: str = AUTH_HEADER_NAME,
147
208
  header_prefix: str = AUTH_HEADER_PREFIX,
@@ -161,6 +222,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
161
222
  - bind_ip: Strict - reject if IP changes
162
223
  - bind_fingerprint: Soft - log warning if fingerprint changes
163
224
  - allow_ip_change_with_reauth: Allow IP change on re-authentication
225
+ auto_assign_default_role: If True, automatically assign require_role to users
226
+ with no roles for this app (default: False).
227
+ SECURITY: Only enable if explicitly needed - requires
228
+ default_role in manifest to match require_role.
164
229
  cookie_name: Name of auth cookie (default: mdb_auth_token)
165
230
  header_name: Name of auth header (default: Authorization)
166
231
  header_prefix: Prefix for header value (default: "Bearer ")
@@ -172,6 +237,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
172
237
  self._public_routes = public_routes or []
173
238
  self._role_hierarchy = role_hierarchy
174
239
  self._session_binding = session_binding or {}
240
+ self._auto_assign_default_role = auto_assign_default_role
175
241
  self._cookie_name = cookie_name
176
242
  self._header_name = header_name
177
243
  self._header_prefix = header_prefix
@@ -249,11 +315,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
249
315
  )
250
316
 
251
317
  if not has_required_role:
252
- # Auto-assign required role if user has no roles for this app
253
- # This is a fallback for SSO scenarios where users might be authenticated
254
- # but not yet assigned roles. Only do this if they have NO roles (not if
255
- # they have other roles but not the required one - prevents privilege escalation).
256
- if not user_roles:
318
+ # Auto-assign required role ONLY if explicitly enabled and user has no roles
319
+ # SECURITY: This is opt-in to prevent privilege escalation. Only enable if
320
+ # explicitly needed and default_role matches require_role in manifest.
321
+ if not user_roles and self._auto_assign_default_role:
257
322
  user_email = user.get("email")
258
323
  if user_email:
259
324
  try:
@@ -269,7 +334,8 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
269
334
  request.state.user_roles = [self._require_role]
270
335
  logger.info(
271
336
  f"Auto-assigned role '{self._require_role}' to user "
272
- f"{user_email} for app '{self._app_slug}'"
337
+ f"{user_email} for app '{self._app_slug}' "
338
+ f"(auto_assign_default_role enabled)"
273
339
  )
274
340
  else:
275
341
  logger.warning(
@@ -327,17 +393,29 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
327
393
  logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
328
394
  return "Session bound to different IP address"
329
395
 
330
- # Check fingerprint binding (soft check - just warn)
331
- if self._session_binding.get("bind_fingerprint", True):
396
+ # Check fingerprint binding (strict by default for security)
397
+ bind_fingerprint = self._session_binding.get("bind_fingerprint", True)
398
+ strict_fingerprint = self._session_binding.get(
399
+ "strict_fingerprint", True
400
+ ) # Default: strict
401
+ if bind_fingerprint:
332
402
  token_fp = payload.get("fp")
333
403
  if token_fp:
334
404
  client_fp = _compute_fingerprint(request)
335
405
  if client_fp != token_fp:
336
- logger.warning(
337
- f"Session fingerprint mismatch for user {payload.get('email')}"
338
- )
339
- # Soft check - don't reject, just log
340
- # Could be legitimate (browser update, different device)
406
+ if strict_fingerprint:
407
+ logger.warning(
408
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
409
+ f"rejecting request (strict_fingerprint=True)"
410
+ )
411
+ return "Session bound to different device/fingerprint"
412
+ else:
413
+ logger.warning(
414
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
415
+ f"allowing (strict_fingerprint=False)"
416
+ )
417
+ # Soft check - don't reject, just log
418
+ # Could be legitimate (browser update, different device)
341
419
 
342
420
  return None
343
421
 
@@ -421,6 +499,17 @@ def create_shared_auth_middleware(
421
499
  """
422
500
  require_role = manifest_auth.get("require_role")
423
501
  public_routes = manifest_auth.get("public_routes", [])
502
+ auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
503
+ default_role = manifest_auth.get("default_role")
504
+
505
+ # Security: Only allow auto-assignment if default_role matches require_role
506
+ if auto_assign_default_role and require_role and default_role != require_role:
507
+ logger.warning(
508
+ f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
509
+ f"does not match require_role '{require_role}' for app '{app_slug}'. "
510
+ f"Auto-assignment disabled for security."
511
+ )
512
+ auto_assign_default_role = False
424
513
 
425
514
  # Build role hierarchy from manifest if available
426
515
  role_hierarchy = None
@@ -443,6 +532,7 @@ def create_shared_auth_middleware(
443
532
  require_role=require_role,
444
533
  public_routes=public_routes,
445
534
  role_hierarchy=role_hierarchy,
535
+ auto_assign_default_role=auto_assign_default_role,
446
536
  )
447
537
 
448
538
  return ConfiguredSharedAuthMiddleware
@@ -503,12 +593,13 @@ def _extract_token_helper(
503
593
  return None
504
594
 
505
595
 
506
- def _create_lazy_middleware_class(
596
+ def _create_lazy_middleware_class( # noqa: C901
507
597
  app_slug: str,
508
598
  require_role: str | None,
509
599
  public_routes: list[str],
510
600
  role_hierarchy: dict[str, list[str]] | None,
511
601
  session_binding: dict[str, Any],
602
+ auto_assign_default_role: bool = False,
512
603
  ) -> type:
513
604
  """Create the LazySharedAuthMiddleware class with configuration."""
514
605
 
@@ -527,6 +618,7 @@ def _create_lazy_middleware_class(
527
618
  self._public_routes = public_routes
528
619
  self._role_hierarchy = role_hierarchy
529
620
  self._session_binding = session_binding
621
+ self._auto_assign_default_role = auto_assign_default_role
530
622
  self._cookie_name = AUTH_COOKIE_NAME
531
623
  self._header_name = AUTH_HEADER_NAME
532
624
  self._header_prefix = AUTH_HEADER_PREFIX
@@ -681,8 +773,9 @@ def _create_lazy_middleware_class(
681
773
  if has_required_role:
682
774
  return None
683
775
 
684
- # Auto-assign required role if user has no roles for this app
685
- if not user_roles:
776
+ # Auto-assign required role ONLY if explicitly enabled and user has no roles
777
+ # SECURITY: This is opt-in to prevent privilege escalation
778
+ if not user_roles and self._auto_assign_default_role:
686
779
  await self._try_auto_assign_role(user, user_pool, request)
687
780
 
688
781
  # Check again after potential auto-assignment
@@ -707,10 +800,8 @@ def _create_lazy_middleware_class(
707
800
  """
708
801
  Attempt to auto-assign required role to user.
709
802
 
710
- This is a fallback for SSO scenarios where users might be authenticated
711
- but not yet assigned roles. Only do this if they have NO roles (not if
712
- they have other roles but not the required one - prevents privilege
713
- escalation).
803
+ SECURITY: Only called if auto_assign_default_role is enabled and user has
804
+ no roles. This prevents privilege escalation.
714
805
  """
715
806
  user_email = user.get("email")
716
807
  if not user_email:
@@ -729,7 +820,8 @@ def _create_lazy_middleware_class(
729
820
  request.state.user_roles = [self._require_role]
730
821
  logger.info(
731
822
  f"Auto-assigned role '{self._require_role}' to user "
732
- f"{user_email} for app '{self._app_slug}'"
823
+ f"{user_email} for app '{self._app_slug}' "
824
+ f"(auto_assign_default_role enabled)"
733
825
  )
734
826
  else:
735
827
  logger.warning(
@@ -769,8 +861,10 @@ def _create_lazy_middleware_class(
769
861
  if ip_error:
770
862
  return ip_error
771
863
 
772
- # Check fingerprint binding (soft check - just warn)
773
- self._check_fingerprint_binding(request, payload)
864
+ # Check fingerprint binding (strict by default)
865
+ fingerprint_error = await self._check_fingerprint_binding(request, payload)
866
+ if fingerprint_error:
867
+ return fingerprint_error
774
868
 
775
869
  return None
776
870
 
@@ -794,19 +888,38 @@ def _create_lazy_middleware_class(
794
888
 
795
889
  return None
796
890
 
797
- def _check_fingerprint_binding(self, request: Request, payload: dict) -> None:
798
- """Check fingerprint binding from token payload (soft check - just warn)."""
891
+ async def _check_fingerprint_binding(self, request: Request, payload: dict) -> str | None:
892
+ """
893
+ Check fingerprint binding from token payload.
894
+
895
+ Returns error message if validation fails, None if OK.
896
+ """
799
897
  if not self._session_binding.get("bind_fingerprint", True):
800
- return
898
+ return None
801
899
 
802
900
  token_fp = payload.get("fp")
803
901
  if not token_fp:
804
- return
902
+ return None
805
903
 
904
+ strict_fingerprint = self._session_binding.get(
905
+ "strict_fingerprint", True
906
+ ) # Default: strict
806
907
  client_fp = _compute_fingerprint(request)
807
908
  if client_fp != token_fp:
808
- logger.warning(f"Session fingerprint mismatch for user {payload.get('email')}")
809
- # Soft check - don't reject, just log
909
+ if strict_fingerprint:
910
+ logger.warning(
911
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
912
+ f"rejecting request (strict_fingerprint=True)"
913
+ )
914
+ return "Session bound to different device/fingerprint"
915
+ else:
916
+ logger.warning(
917
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
918
+ f"allowing (strict_fingerprint=False)"
919
+ )
920
+ # Soft check - don't reject, just log
921
+ return None
922
+ return None
810
923
 
811
924
  return LazySharedAuthMiddleware
812
925
 
@@ -839,9 +952,26 @@ def create_shared_auth_middleware_lazy(
839
952
  """
840
953
  require_role = manifest_auth.get("require_role")
841
954
  public_routes = manifest_auth.get("public_routes", [])
955
+ auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
956
+ default_role = manifest_auth.get("default_role")
957
+
958
+ # Security: Only allow auto-assignment if default_role matches require_role
959
+ if auto_assign_default_role and require_role and default_role != require_role:
960
+ logger.warning(
961
+ f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
962
+ f"does not match require_role '{require_role}' for app '{app_slug}'. "
963
+ f"Auto-assignment disabled for security."
964
+ )
965
+ auto_assign_default_role = False
966
+
842
967
  role_hierarchy = _build_role_hierarchy(manifest_auth)
843
968
  session_binding = manifest_auth.get("session_binding", {})
844
969
 
845
970
  return _create_lazy_middleware_class(
846
- app_slug, require_role, public_routes, role_hierarchy, session_binding
971
+ app_slug,
972
+ require_role,
973
+ public_routes,
974
+ role_hierarchy,
975
+ session_binding,
976
+ auto_assign_default_role,
847
977
  )
@@ -119,6 +119,7 @@ class SharedUserPool:
119
119
  jwt_algorithm: str = DEFAULT_JWT_ALGORITHM,
120
120
  token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
121
121
  allow_insecure_dev: bool = False,
122
+ blacklist_fail_closed: bool = True,
122
123
  ):
123
124
  """
124
125
  Initialize the shared user pool.
@@ -138,6 +139,9 @@ class SharedUserPool:
138
139
  token_expiry_hours: Token expiry in hours (default: 24)
139
140
  allow_insecure_dev: Allow insecure auto-generated secret for local
140
141
  development only. NEVER use in production!
142
+ blacklist_fail_closed: If True (default), reject tokens when blacklist check
143
+ fails (secure). If False, allow tokens when check fails
144
+ (availability). SECURITY: Default is True for security.
141
145
 
142
146
  Raises:
143
147
  JWTSecretError: If no JWT secret is provided and allow_insecure_dev=False
@@ -146,6 +150,7 @@ class SharedUserPool:
146
150
  self._db = mongo_db
147
151
  self._collection = mongo_db[SHARED_USERS_COLLECTION]
148
152
  self._blacklist_collection = mongo_db[TOKEN_BLACKLIST_COLLECTION]
153
+ self._blacklist_fail_closed = blacklist_fail_closed
149
154
 
150
155
  # Validate algorithm
151
156
  if jwt_algorithm not in SUPPORTED_ALGORITHMS:
@@ -453,8 +458,19 @@ class SharedUserPool:
453
458
  return False
454
459
  except PyMongoError as e:
455
460
  logger.exception(f"Error checking token blacklist: {e}")
456
- # Fail open for availability (can be changed to fail closed for security)
457
- return False
461
+ # Fail closed for security by default (reject token if we can't verify)
462
+ if self._blacklist_fail_closed:
463
+ logger.warning(
464
+ "Token blacklist check failed - rejecting token for security "
465
+ "(blacklist_fail_closed=True)"
466
+ )
467
+ return True # Token IS revoked (reject access)
468
+ # Fail open only if explicitly configured (availability over security)
469
+ logger.warning(
470
+ "Token blacklist check failed - allowing token for availability "
471
+ "(blacklist_fail_closed=False). SECURITY RISK: Revoked tokens may be accepted."
472
+ )
473
+ return False # Token NOT revoked (allow access)
458
474
 
459
475
  async def revoke_token(
460
476
  self,
mdb_engine/core/engine.py CHANGED
@@ -155,6 +155,12 @@ class MongoDBEngine:
155
155
  # Store app token cache for auto-retrieval
156
156
  self._app_token_cache: dict[str, str] = {}
157
157
 
158
+ # Async lock for thread-safe shared user pool initialization
159
+ import asyncio
160
+
161
+ self._shared_user_pool_lock = asyncio.Lock()
162
+ self._shared_user_pool_initializing = False
163
+
158
164
  async def initialize(self) -> None:
159
165
  """
160
166
  Initialize the MongoDB Engine.
@@ -1960,9 +1966,26 @@ class MongoDBEngine:
1960
1966
  )
1961
1967
 
1962
1968
  # State for parent app
1963
- mounted_apps: list[dict[str, Any]] = []
1969
+ # Build initial mounted_apps metadata synchronously so get_mounted_apps() works
1970
+ # immediately after create_multi_app() returns (before lifespan runs)
1971
+ mounted_apps: list[dict[str, Any]] = [
1972
+ {
1973
+ "slug": app_config["slug"],
1974
+ "path_prefix": app_config["path_prefix"],
1975
+ "status": "pending", # Will be updated in lifespan to "mounted" or "failed"
1976
+ "manifest_path": str(app_config["manifest"]),
1977
+ }
1978
+ for app_config in apps
1979
+ ]
1964
1980
  shared_user_pool_initialized = False
1965
1981
 
1982
+ def _find_mounted_app_entry(slug: str) -> dict[str, Any] | None:
1983
+ """Find mounted app entry by slug."""
1984
+ for entry in mounted_apps:
1985
+ if entry.get("slug") == slug:
1986
+ return entry
1987
+ return None
1988
+
1966
1989
  @asynccontextmanager
1967
1990
  async def lifespan(app: FastAPI):
1968
1991
  """Lifespan context manager for parent app."""
@@ -2133,14 +2156,25 @@ class MongoDBEngine:
2133
2156
 
2134
2157
  # Mount child app at path prefix
2135
2158
  app.mount(path_prefix, child_app)
2136
- mounted_apps.append(
2137
- {
2138
- "slug": slug,
2139
- "path_prefix": path_prefix,
2140
- "status": "mounted",
2141
- "manifest": app_manifest_data,
2142
- }
2143
- )
2159
+ # Update existing entry instead of appending
2160
+ entry = _find_mounted_app_entry(slug)
2161
+ if entry:
2162
+ entry.update(
2163
+ {
2164
+ "status": "mounted",
2165
+ "manifest": app_manifest_data,
2166
+ }
2167
+ )
2168
+ else:
2169
+ # Fallback: append if entry not found (shouldn't happen)
2170
+ mounted_apps.append(
2171
+ {
2172
+ "slug": slug,
2173
+ "path_prefix": path_prefix,
2174
+ "status": "mounted",
2175
+ "manifest": app_manifest_data,
2176
+ }
2177
+ )
2144
2178
  logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
2145
2179
 
2146
2180
  except FileNotFoundError as e:
@@ -2149,15 +2183,26 @@ class MongoDBEngine:
2149
2183
  f"manifest.json not found at {manifest_path}"
2150
2184
  )
2151
2185
  logger.error(error_msg, exc_info=True)
2152
- mounted_apps.append(
2153
- {
2154
- "slug": slug,
2155
- "path_prefix": path_prefix,
2156
- "status": "failed",
2157
- "error": error_msg,
2158
- "manifest_path": str(manifest_path),
2159
- }
2160
- )
2186
+ # Update existing entry instead of appending
2187
+ entry = _find_mounted_app_entry(slug)
2188
+ if entry:
2189
+ entry.update(
2190
+ {
2191
+ "status": "failed",
2192
+ "error": error_msg,
2193
+ }
2194
+ )
2195
+ else:
2196
+ # Fallback: append if entry not found (shouldn't happen)
2197
+ mounted_apps.append(
2198
+ {
2199
+ "slug": slug,
2200
+ "path_prefix": path_prefix,
2201
+ "status": "failed",
2202
+ "error": error_msg,
2203
+ "manifest_path": str(manifest_path),
2204
+ }
2205
+ )
2161
2206
  if strict:
2162
2207
  raise ValueError(error_msg) from e
2163
2208
  continue
@@ -2167,71 +2212,111 @@ class MongoDBEngine:
2167
2212
  f"Invalid JSON in manifest.json at {manifest_path}: {e}"
2168
2213
  )
2169
2214
  logger.error(error_msg, exc_info=True)
2170
- mounted_apps.append(
2171
- {
2172
- "slug": slug,
2173
- "path_prefix": path_prefix,
2174
- "status": "failed",
2175
- "error": error_msg,
2176
- "manifest_path": str(manifest_path),
2177
- }
2178
- )
2215
+ # Update existing entry instead of appending
2216
+ entry = _find_mounted_app_entry(slug)
2217
+ if entry:
2218
+ entry.update(
2219
+ {
2220
+ "status": "failed",
2221
+ "error": error_msg,
2222
+ }
2223
+ )
2224
+ else:
2225
+ # Fallback: append if entry not found (shouldn't happen)
2226
+ mounted_apps.append(
2227
+ {
2228
+ "slug": slug,
2229
+ "path_prefix": path_prefix,
2230
+ "status": "failed",
2231
+ "error": error_msg,
2232
+ "manifest_path": str(manifest_path),
2233
+ }
2234
+ )
2179
2235
  if strict:
2180
2236
  raise ValueError(error_msg) from e
2181
2237
  continue
2182
2238
  except ValueError as e:
2183
2239
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2184
2240
  logger.error(error_msg, exc_info=True)
2185
- mounted_apps.append(
2186
- {
2187
- "slug": slug,
2188
- "path_prefix": path_prefix,
2189
- "status": "failed",
2190
- "error": error_msg,
2191
- "manifest_path": str(manifest_path),
2192
- }
2193
- )
2241
+ # Update existing entry instead of appending
2242
+ entry = _find_mounted_app_entry(slug)
2243
+ if entry:
2244
+ entry.update(
2245
+ {
2246
+ "status": "failed",
2247
+ "error": error_msg,
2248
+ }
2249
+ )
2250
+ else:
2251
+ # Fallback: append if entry not found (shouldn't happen)
2252
+ mounted_apps.append(
2253
+ {
2254
+ "slug": slug,
2255
+ "path_prefix": path_prefix,
2256
+ "status": "failed",
2257
+ "error": error_msg,
2258
+ "manifest_path": str(manifest_path),
2259
+ }
2260
+ )
2194
2261
  if strict:
2195
2262
  raise ValueError(error_msg) from e
2196
2263
  continue
2197
2264
  except (KeyError, RuntimeError) as e:
2198
2265
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2199
2266
  logger.error(error_msg, exc_info=True)
2200
- mounted_apps.append(
2201
- {
2202
- "slug": slug,
2203
- "path_prefix": path_prefix,
2204
- "status": "failed",
2205
- "error": error_msg,
2206
- "manifest_path": str(manifest_path),
2207
- }
2208
- )
2267
+ # Update existing entry instead of appending
2268
+ entry = _find_mounted_app_entry(slug)
2269
+ if entry:
2270
+ entry.update(
2271
+ {
2272
+ "status": "failed",
2273
+ "error": error_msg,
2274
+ }
2275
+ )
2276
+ else:
2277
+ # Fallback: append if entry not found (shouldn't happen)
2278
+ mounted_apps.append(
2279
+ {
2280
+ "slug": slug,
2281
+ "path_prefix": path_prefix,
2282
+ "status": "failed",
2283
+ "error": error_msg,
2284
+ "manifest_path": str(manifest_path),
2285
+ }
2286
+ )
2209
2287
  if strict:
2210
2288
  raise RuntimeError(error_msg) from e
2211
2289
  continue
2212
2290
  except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
2213
2291
  error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
2214
2292
  logger.error(error_msg, exc_info=True)
2215
- mounted_apps.append(
2216
- {
2217
- "slug": slug,
2218
- "path_prefix": path_prefix,
2219
- "status": "failed",
2220
- "error": error_msg,
2221
- "manifest_path": str(manifest_path),
2222
- }
2223
- )
2293
+ # Update existing entry instead of appending
2294
+ entry = _find_mounted_app_entry(slug)
2295
+ if entry:
2296
+ entry.update(
2297
+ {
2298
+ "status": "failed",
2299
+ "error": error_msg,
2300
+ }
2301
+ )
2302
+ else:
2303
+ # Fallback: append if entry not found (shouldn't happen)
2304
+ mounted_apps.append(
2305
+ {
2306
+ "slug": slug,
2307
+ "path_prefix": path_prefix,
2308
+ "status": "failed",
2309
+ "error": error_msg,
2310
+ "manifest_path": str(manifest_path),
2311
+ }
2312
+ )
2224
2313
  if strict:
2225
2314
  raise RuntimeError(error_msg) from e
2226
2315
  continue
2227
2316
 
2228
- # Expose engine and mounted apps info on parent app state
2229
- app.state.engine = engine
2317
+ # Update app.state.mounted_apps with final status (entries already updated in place)
2318
+ # This ensures the state reflects the final mounted_apps list
2230
2319
  app.state.mounted_apps = mounted_apps
2231
- app.state.is_multi_app = True
2232
-
2233
- # Store app reference in engine for get_mounted_apps()
2234
- engine._multi_app_instance = app
2235
2320
 
2236
2321
  yield
2237
2322
 
@@ -2241,6 +2326,14 @@ class MongoDBEngine:
2241
2326
  # Create parent FastAPI app
2242
2327
  parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
2243
2328
 
2329
+ # Set mounted_apps immediately so get_mounted_apps() works before lifespan runs
2330
+ parent_app.state.mounted_apps = mounted_apps
2331
+ parent_app.state.is_multi_app = True
2332
+ parent_app.state.engine = engine
2333
+
2334
+ # Store app reference in engine for get_mounted_apps()
2335
+ engine._multi_app_instance = parent_app
2336
+
2244
2337
  # Add request scope middleware
2245
2338
  from starlette.middleware.base import BaseHTTPMiddleware
2246
2339
 
@@ -2629,17 +2722,41 @@ class MongoDBEngine:
2629
2722
  or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
2630
2723
  )
2631
2724
 
2632
- # Create or get shared user pool
2633
- if not hasattr(self, "_shared_user_pool") or self._shared_user_pool is None:
2634
- self._shared_user_pool = SharedUserPool(
2635
- self._connection_manager.mongo_db,
2636
- allow_insecure_dev=is_dev,
2637
- )
2638
- await self._shared_user_pool.ensure_indexes()
2639
- logger.info("SharedUserPool initialized")
2725
+ # Thread-safe initialization with async lock to prevent race conditions
2726
+ async with self._shared_user_pool_lock:
2727
+ # Check if another coroutine is initializing
2728
+ if self._shared_user_pool_initializing:
2729
+ # Wait for other initialization to complete
2730
+ while self._shared_user_pool_initializing:
2731
+ import asyncio
2732
+
2733
+ await asyncio.sleep(0.01) # Small delay to avoid busy-waiting
2734
+ # After waiting, check if pool was initialized
2735
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2736
+ app.state.user_pool = self._shared_user_pool
2737
+ return
2738
+
2739
+ # Check if already initialized (double-check pattern)
2740
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2741
+ app.state.user_pool = self._shared_user_pool
2742
+ return
2640
2743
 
2641
- # Expose user pool on app.state for middleware to access
2642
- app.state.user_pool = self._shared_user_pool
2744
+ # Mark as initializing
2745
+ self._shared_user_pool_initializing = True
2746
+ try:
2747
+ # Create shared user pool
2748
+ self._shared_user_pool = SharedUserPool(
2749
+ self._connection_manager.mongo_db,
2750
+ allow_insecure_dev=is_dev,
2751
+ )
2752
+ await self._shared_user_pool.ensure_indexes()
2753
+ logger.info("SharedUserPool initialized")
2754
+
2755
+ # Expose user pool on app.state for middleware to access
2756
+ app.state.user_pool = self._shared_user_pool
2757
+ finally:
2758
+ # Always clear the initializing flag
2759
+ self._shared_user_pool_initializing = False
2643
2760
 
2644
2761
  # Seed demo users to SharedUserPool if configured in manifest
2645
2762
  if manifest:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdb-engine
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: MongoDB Engine
5
5
  Home-page: https://github.com/ranfysvalle02/mdb-engine
6
6
  Author: Fabian Valle
@@ -1,5 +1,5 @@
1
1
  mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
2
- mdb_engine/__init__.py,sha256=hYVtAlyPN9kRthzuVQ5A_m-5IIh5Q4LJIyzOzUQtGE8,3172
2
+ mdb_engine/__init__.py,sha256=6Uve36cIfvCcTfYTZEHKkZoD9sz4zw3Wfz76y0FEvUo,3242
3
3
  mdb_engine/config.py,sha256=DTAyxfKB8ogyI0v5QR9Y-SJOgXQr_eDBCKxNBSqEyLc,7269
4
4
  mdb_engine/constants.py,sha256=eaotvW57TVOg7rRbLziGrVNoP7adgw_G9iVByHezc_A,7837
5
5
  mdb_engine/dependencies.py,sha256=MJuYQhZ9ZGzXlip1ha5zba9Rvn04HDPWahJFJH81Q2s,14107
@@ -26,8 +26,8 @@ mdb_engine/auth/provider.py,sha256=FOSHn-jp4VYtxvmjnzho5kH6y-xDKWbcKUorYRRl1C4,2
26
26
  mdb_engine/auth/rate_limiter.py,sha256=l3EYZE1Kz9yVfZwNrKq_1AgdD7GXB1WOLSqqGQVSSgA,15808
27
27
  mdb_engine/auth/restrictions.py,sha256=tOyQBO_w0bK9zmTsOPZf9cbvh4oITvpNfSxIXt-XrcU,8824
28
28
  mdb_engine/auth/session_manager.py,sha256=ywWJjTarm-obgJ3zO3s-1cdqEYe0XrozlY00q_yMJ8I,15396
29
- mdb_engine/auth/shared_middleware.py,sha256=nOiswgK8ptx7zUG70YN7LQNhF8PSwwkM_atAO2CAzo4,32100
30
- mdb_engine/auth/shared_users.py,sha256=25OBks4VRHaYZW7R61vnplV7wmr7RRpDctSgnej_nxc,26773
29
+ mdb_engine/auth/shared_middleware.py,sha256=0iSbRkwdivL1NIj7Gr161qPJiqcw0JafOpZLCkXjT7k,37633
30
+ mdb_engine/auth/shared_users.py,sha256=KTc4D9zRaYaIVto7PqyWd5RT4J97cp6AnJ5i_PR_7eg,27775
31
31
  mdb_engine/auth/token_lifecycle.py,sha256=Q9S1X2Y6W7Ckt5PvyYXswBRh2Tg9DGpyRv_3Xve7VYQ,6708
32
32
  mdb_engine/auth/token_store.py,sha256=-B8j5RH5YEoKsswF4rnMoI51BaxMe4icke3kuehXmcI,9121
33
33
  mdb_engine/auth/users.py,sha256=t9Us2_A_wKOL9qy1O_SBwTvapAyNztn0v8padxJVq6A,49891
@@ -46,7 +46,7 @@ mdb_engine/core/app_registration.py,sha256=7szt2a7aBkpSppjmhdkkPPYMKGKo0MkLKZeEe
46
46
  mdb_engine/core/app_secrets.py,sha256=bo-syg9UUATibNyXEZs-0TTYWG-JaY-2S0yNSGA12n0,10524
47
47
  mdb_engine/core/connection.py,sha256=XnwuPG34pJ7kJGJ84T0mhj1UZ6_CLz_9qZf6NRYGIS8,8346
48
48
  mdb_engine/core/encryption.py,sha256=RZ5LPF5g28E3ZBn6v1IMw_oas7u9YGFtBcEj8lTi9LM,7515
49
- mdb_engine/core/engine.py,sha256=EH3vPgdJuNsg8JiIki4Auz1slrM8dA1WXwfOjPeuh8Y,118185
49
+ mdb_engine/core/engine.py,sha256=ManZZUCGsI0mNlBKL7CxofX1L1tkJDNI4-8YHBKGYYk,123708
50
50
  mdb_engine/core/index_management.py,sha256=9-r7MIy3JnjQ35sGqsbj8K_I07vAUWtAVgSWC99lJcE,5555
51
51
  mdb_engine/core/manifest.py,sha256=jguhjVPAHMZGxOJcdSGouv9_XiKmxUjDmyjn2yXHCj4,139205
52
52
  mdb_engine/core/ray_integration.py,sha256=vexYOzztscvRYje1xTNmXJbi99oJxCaVJAwKfTNTF_E,13610
@@ -89,9 +89,9 @@ mdb_engine/routing/__init__.py,sha256=reupjHi_RTc2ZBA4AH5XzobAmqy4EQIsfSUcTkFknU
89
89
  mdb_engine/routing/websockets.py,sha256=3X4OjQv_Nln4UmeifJky0gFhMG8A6alR77I8g1iIOLY,29311
90
90
  mdb_engine/utils/__init__.py,sha256=lDxQSGqkV4fVw5TWIk6FA6_eey_ZnEtMY0fir3cpAe8,236
91
91
  mdb_engine/utils/mongo.py,sha256=Oqtv4tQdpiiZzrilGLEYQPo8Vmh8WsTQypxQs8Of53s,3369
92
- mdb_engine-0.4.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
- mdb_engine-0.4.1.dist-info/METADATA,sha256=kyV21MMeAU9NF0MuVLYHWX6k1I-GIG01YZ45iNEZIj4,15810
94
- mdb_engine-0.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
- mdb_engine-0.4.1.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
- mdb_engine-0.4.1.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
- mdb_engine-0.4.1.dist-info/RECORD,,
92
+ mdb_engine-0.4.2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
+ mdb_engine-0.4.2.dist-info/METADATA,sha256=1wI8au6nvUczKwC6e-MoS7fvqHv-TozwjswYSg5TT6w,15810
94
+ mdb_engine-0.4.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
+ mdb_engine-0.4.2.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
+ mdb_engine-0.4.2.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
+ mdb_engine-0.4.2.dist-info/RECORD,,