mdb-engine 0.4.1__py3-none-any.whl → 0.4.3__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.3" # Feature: Automatic route import for multi-app deployments
86
+ # Routes from web.py/routes.py are now automatically imported when using create_multi_app()
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.
@@ -1723,6 +1729,199 @@ class MongoDBEngine:
1723
1729
 
1724
1730
  return validation_errors
1725
1731
 
1732
+ def _import_app_routes(self, child_app: "FastAPI", manifest_path: Path, slug: str) -> None:
1733
+ """
1734
+ Automatically discover and import route modules for a child app.
1735
+
1736
+ This method looks for route modules (web.py, routes.py) in the same directory
1737
+ as the manifest and imports them so that route decorators are executed and
1738
+ routes are registered on the child app.
1739
+
1740
+ Args:
1741
+ child_app: The FastAPI child app to register routes on
1742
+ manifest_path: Path to the manifest.json file
1743
+ slug: App slug for logging
1744
+
1745
+ The method tries multiple strategies:
1746
+ 1. Look for 'web.py' in the manifest directory
1747
+ 2. Look for 'routes.py' in the manifest directory
1748
+ 3. Check manifest for explicit 'routes_module' field (future support)
1749
+
1750
+ When importing, the method ensures that route decorators in the imported module
1751
+ reference the child_app by temporarily injecting it into the module namespace.
1752
+ """
1753
+ import importlib.util
1754
+ import sys
1755
+
1756
+ manifest_dir = manifest_path.parent
1757
+
1758
+ # Try to find route modules in order of preference
1759
+ route_module_paths = [
1760
+ manifest_dir / "web.py",
1761
+ manifest_dir / "routes.py",
1762
+ ]
1763
+
1764
+ # Also check for routes_module in manifest (future support)
1765
+ try:
1766
+ import json
1767
+
1768
+ with open(manifest_path) as f:
1769
+ manifest_data = json.load(f)
1770
+ routes_module = manifest_data.get("routes_module")
1771
+ if routes_module:
1772
+ # Support both relative (to manifest dir) and absolute paths
1773
+ if routes_module.startswith("/"):
1774
+ route_module_paths.insert(0, Path(routes_module))
1775
+ else:
1776
+ route_module_paths.insert(0, manifest_dir / routes_module)
1777
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
1778
+ pass
1779
+
1780
+ imported = False
1781
+ module_name = None
1782
+ route_module = None
1783
+ manifest_dir_str = None
1784
+ path_inserted = False
1785
+
1786
+ for route_module_path in route_module_paths:
1787
+ if not route_module_path.exists():
1788
+ continue
1789
+
1790
+ # Create a unique module name to avoid conflicts
1791
+ module_name = f"mdb_engine_imported_routes_{slug}_{id(child_app)}"
1792
+
1793
+ try:
1794
+ # Validate file is actually a Python file
1795
+ if not route_module_path.suffix == ".py":
1796
+ logger.debug(f"Skipping non-Python file '{route_module_path}' for app '{slug}'")
1797
+ continue
1798
+
1799
+ # Load the module spec
1800
+ spec = importlib.util.spec_from_file_location(module_name, route_module_path)
1801
+ if spec is None or spec.loader is None:
1802
+ logger.warning(
1803
+ f"Could not create spec for route module '{route_module_path}' "
1804
+ f"for app '{slug}'"
1805
+ )
1806
+ continue
1807
+
1808
+ route_module = importlib.util.module_from_spec(spec)
1809
+
1810
+ # CRITICAL: Inject child_app into module namespace BEFORE loading
1811
+ # This ensures that @app.get(), @app.post(), etc. decorators in the
1812
+ # imported module will reference our child_app instead of creating a new one
1813
+ route_module.app = child_app
1814
+ route_module.engine = self # Also provide engine reference for dependencies
1815
+
1816
+ # Add to sys.modules temporarily to handle relative imports
1817
+ # Use a try-finally to ensure cleanup even on exceptions
1818
+ sys.modules[module_name] = route_module
1819
+
1820
+ # Store route count before import
1821
+ routes_before = len(child_app.routes)
1822
+
1823
+ # Add manifest directory to Python path temporarily for relative imports
1824
+ # This allows route modules to import sibling modules
1825
+ manifest_dir_str = str(manifest_dir.resolve())
1826
+ path_inserted = manifest_dir_str not in sys.path
1827
+ if path_inserted:
1828
+ sys.path.insert(0, manifest_dir_str)
1829
+
1830
+ try:
1831
+ # Execute the module (runs route decorators with injected app)
1832
+ spec.loader.exec_module(route_module)
1833
+ except SyntaxError as e:
1834
+ logger.warning(
1835
+ f"Syntax error in route module '{route_module_path}' "
1836
+ f"for app '{slug}': {e}. Skipping this module."
1837
+ )
1838
+ continue
1839
+ except ImportError as e:
1840
+ # ImportError might be due to missing dependencies - log but don't fail
1841
+ logger.debug(
1842
+ f"Import error in route module '{route_module_path}' "
1843
+ f"for app '{slug}': {e}. "
1844
+ "This may be OK if dependencies are optional."
1845
+ )
1846
+ # Check if it's a critical import (like FastAPI) vs optional dependency
1847
+ error_str = str(e).lower()
1848
+ if "fastapi" in error_str or "starlette" in error_str:
1849
+ logger.warning(
1850
+ f"Route module '{route_module_path}' for app '{slug}' "
1851
+ "requires FastAPI/Starlette but they're not available. "
1852
+ "Routes will not be registered."
1853
+ )
1854
+ continue
1855
+ finally:
1856
+ # Remove from path only if we added it
1857
+ if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
1858
+ try:
1859
+ sys.path.remove(manifest_dir_str)
1860
+ except ValueError:
1861
+ # Path might have been removed already - ignore
1862
+ pass
1863
+
1864
+ # Check if module overwrote app (shouldn't happen in well-structured modules)
1865
+ module_app = getattr(route_module, "app", None)
1866
+ if module_app is not None and module_app is not child_app:
1867
+ import warnings
1868
+
1869
+ warning_msg = (
1870
+ f"Route module '{route_module_path.name}' for app '{slug}' "
1871
+ "created its own app instance. Routes defined before app creation "
1872
+ "are registered, but routes defined after may not be. "
1873
+ "Consider restructuring the module to use the injected 'app' variable."
1874
+ )
1875
+ logger.warning(warning_msg)
1876
+ warnings.warn(warning_msg, UserWarning, stacklevel=2)
1877
+
1878
+ routes_after = len(child_app.routes)
1879
+ routes_added = routes_after - routes_before
1880
+
1881
+ if routes_added > 0:
1882
+ logger.info(
1883
+ f"✅ Auto-imported routes from '{route_module_path.name}' "
1884
+ f"for app '{slug}'. Added {routes_added} route(s) "
1885
+ f"(total: {routes_after})"
1886
+ )
1887
+ else:
1888
+ logger.debug(
1889
+ f"Route module '{route_module_path.name}' for app '{slug}' "
1890
+ "was imported but no new routes were registered. "
1891
+ "This may be expected if routes are registered conditionally."
1892
+ )
1893
+
1894
+ imported = True
1895
+ break
1896
+
1897
+ except (ValueError, TypeError, AttributeError, RuntimeError, OSError) as e:
1898
+ logger.warning(
1899
+ f"Unexpected error importing route module '{route_module_path}' "
1900
+ f"for app '{slug}': {e}",
1901
+ exc_info=True,
1902
+ )
1903
+ continue
1904
+ finally:
1905
+ # Clean up temporary module from sys.modules
1906
+ if module_name and module_name in sys.modules:
1907
+ try:
1908
+ del sys.modules[module_name]
1909
+ except KeyError:
1910
+ # Already removed - ignore
1911
+ pass
1912
+ # Ensure path is cleaned up even if exception occurred
1913
+ if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
1914
+ try:
1915
+ sys.path.remove(manifest_dir_str)
1916
+ except ValueError:
1917
+ pass
1918
+
1919
+ if not imported:
1920
+ logger.debug(
1921
+ f"No route modules found for app '{slug}' in {manifest_dir}. "
1922
+ "Routes may be defined elsewhere or app may not have HTTP routes."
1923
+ )
1924
+
1726
1925
  def create_multi_app( # noqa: C901
1727
1926
  self,
1728
1927
  apps: list[dict[str, Any]] | None = None,
@@ -1960,9 +2159,26 @@ class MongoDBEngine:
1960
2159
  )
1961
2160
 
1962
2161
  # State for parent app
1963
- mounted_apps: list[dict[str, Any]] = []
2162
+ # Build initial mounted_apps metadata synchronously so get_mounted_apps() works
2163
+ # immediately after create_multi_app() returns (before lifespan runs)
2164
+ mounted_apps: list[dict[str, Any]] = [
2165
+ {
2166
+ "slug": app_config["slug"],
2167
+ "path_prefix": app_config["path_prefix"],
2168
+ "status": "pending", # Will be updated in lifespan to "mounted" or "failed"
2169
+ "manifest_path": str(app_config["manifest"]),
2170
+ }
2171
+ for app_config in apps
2172
+ ]
1964
2173
  shared_user_pool_initialized = False
1965
2174
 
2175
+ def _find_mounted_app_entry(slug: str) -> dict[str, Any] | None:
2176
+ """Find mounted app entry by slug."""
2177
+ for entry in mounted_apps:
2178
+ if entry.get("slug") == slug:
2179
+ return entry
2180
+ return None
2181
+
1966
2182
  @asynccontextmanager
1967
2183
  async def lifespan(app: FastAPI):
1968
2184
  """Lifespan context manager for parent app."""
@@ -2036,6 +2252,26 @@ class MongoDBEngine:
2036
2252
  on_shutdown=on_shutdown,
2037
2253
  )
2038
2254
 
2255
+ # Automatically import routes from app module
2256
+ # This discovers and imports route modules (web.py, routes.py, etc.)
2257
+ # so that route decorators are executed and routes are registered
2258
+ try:
2259
+ self._import_app_routes(child_app, manifest_path, slug)
2260
+ except (
2261
+ ValueError,
2262
+ TypeError,
2263
+ AttributeError,
2264
+ RuntimeError,
2265
+ ImportError,
2266
+ SyntaxError,
2267
+ OSError,
2268
+ ) as e:
2269
+ logger.warning(
2270
+ f"Failed to auto-import routes for app '{slug}': {e}. "
2271
+ "Routes may need to be imported manually.",
2272
+ exc_info=True,
2273
+ )
2274
+
2039
2275
  # Share user_pool with child app if shared auth is enabled
2040
2276
  if shared_user_pool_initialized and hasattr(app.state, "user_pool"):
2041
2277
  child_app.state.user_pool = app.state.user_pool
@@ -2133,14 +2369,25 @@ class MongoDBEngine:
2133
2369
 
2134
2370
  # Mount child app at path prefix
2135
2371
  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
- )
2372
+ # Update existing entry instead of appending
2373
+ entry = _find_mounted_app_entry(slug)
2374
+ if entry:
2375
+ entry.update(
2376
+ {
2377
+ "status": "mounted",
2378
+ "manifest": app_manifest_data,
2379
+ }
2380
+ )
2381
+ else:
2382
+ # Fallback: append if entry not found (shouldn't happen)
2383
+ mounted_apps.append(
2384
+ {
2385
+ "slug": slug,
2386
+ "path_prefix": path_prefix,
2387
+ "status": "mounted",
2388
+ "manifest": app_manifest_data,
2389
+ }
2390
+ )
2144
2391
  logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
2145
2392
 
2146
2393
  except FileNotFoundError as e:
@@ -2149,15 +2396,26 @@ class MongoDBEngine:
2149
2396
  f"manifest.json not found at {manifest_path}"
2150
2397
  )
2151
2398
  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
- )
2399
+ # Update existing entry instead of appending
2400
+ entry = _find_mounted_app_entry(slug)
2401
+ if entry:
2402
+ entry.update(
2403
+ {
2404
+ "status": "failed",
2405
+ "error": error_msg,
2406
+ }
2407
+ )
2408
+ else:
2409
+ # Fallback: append if entry not found (shouldn't happen)
2410
+ mounted_apps.append(
2411
+ {
2412
+ "slug": slug,
2413
+ "path_prefix": path_prefix,
2414
+ "status": "failed",
2415
+ "error": error_msg,
2416
+ "manifest_path": str(manifest_path),
2417
+ }
2418
+ )
2161
2419
  if strict:
2162
2420
  raise ValueError(error_msg) from e
2163
2421
  continue
@@ -2167,71 +2425,111 @@ class MongoDBEngine:
2167
2425
  f"Invalid JSON in manifest.json at {manifest_path}: {e}"
2168
2426
  )
2169
2427
  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
- )
2428
+ # Update existing entry instead of appending
2429
+ entry = _find_mounted_app_entry(slug)
2430
+ if entry:
2431
+ entry.update(
2432
+ {
2433
+ "status": "failed",
2434
+ "error": error_msg,
2435
+ }
2436
+ )
2437
+ else:
2438
+ # Fallback: append if entry not found (shouldn't happen)
2439
+ mounted_apps.append(
2440
+ {
2441
+ "slug": slug,
2442
+ "path_prefix": path_prefix,
2443
+ "status": "failed",
2444
+ "error": error_msg,
2445
+ "manifest_path": str(manifest_path),
2446
+ }
2447
+ )
2179
2448
  if strict:
2180
2449
  raise ValueError(error_msg) from e
2181
2450
  continue
2182
2451
  except ValueError as e:
2183
2452
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2184
2453
  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
- )
2454
+ # Update existing entry instead of appending
2455
+ entry = _find_mounted_app_entry(slug)
2456
+ if entry:
2457
+ entry.update(
2458
+ {
2459
+ "status": "failed",
2460
+ "error": error_msg,
2461
+ }
2462
+ )
2463
+ else:
2464
+ # Fallback: append if entry not found (shouldn't happen)
2465
+ mounted_apps.append(
2466
+ {
2467
+ "slug": slug,
2468
+ "path_prefix": path_prefix,
2469
+ "status": "failed",
2470
+ "error": error_msg,
2471
+ "manifest_path": str(manifest_path),
2472
+ }
2473
+ )
2194
2474
  if strict:
2195
2475
  raise ValueError(error_msg) from e
2196
2476
  continue
2197
2477
  except (KeyError, RuntimeError) as e:
2198
2478
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2199
2479
  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
- )
2480
+ # Update existing entry instead of appending
2481
+ entry = _find_mounted_app_entry(slug)
2482
+ if entry:
2483
+ entry.update(
2484
+ {
2485
+ "status": "failed",
2486
+ "error": error_msg,
2487
+ }
2488
+ )
2489
+ else:
2490
+ # Fallback: append if entry not found (shouldn't happen)
2491
+ mounted_apps.append(
2492
+ {
2493
+ "slug": slug,
2494
+ "path_prefix": path_prefix,
2495
+ "status": "failed",
2496
+ "error": error_msg,
2497
+ "manifest_path": str(manifest_path),
2498
+ }
2499
+ )
2209
2500
  if strict:
2210
2501
  raise RuntimeError(error_msg) from e
2211
2502
  continue
2212
2503
  except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
2213
2504
  error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
2214
2505
  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
- )
2506
+ # Update existing entry instead of appending
2507
+ entry = _find_mounted_app_entry(slug)
2508
+ if entry:
2509
+ entry.update(
2510
+ {
2511
+ "status": "failed",
2512
+ "error": error_msg,
2513
+ }
2514
+ )
2515
+ else:
2516
+ # Fallback: append if entry not found (shouldn't happen)
2517
+ mounted_apps.append(
2518
+ {
2519
+ "slug": slug,
2520
+ "path_prefix": path_prefix,
2521
+ "status": "failed",
2522
+ "error": error_msg,
2523
+ "manifest_path": str(manifest_path),
2524
+ }
2525
+ )
2224
2526
  if strict:
2225
2527
  raise RuntimeError(error_msg) from e
2226
2528
  continue
2227
2529
 
2228
- # Expose engine and mounted apps info on parent app state
2229
- app.state.engine = engine
2530
+ # Update app.state.mounted_apps with final status (entries already updated in place)
2531
+ # This ensures the state reflects the final mounted_apps list
2230
2532
  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
2533
 
2236
2534
  yield
2237
2535
 
@@ -2241,6 +2539,14 @@ class MongoDBEngine:
2241
2539
  # Create parent FastAPI app
2242
2540
  parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
2243
2541
 
2542
+ # Set mounted_apps immediately so get_mounted_apps() works before lifespan runs
2543
+ parent_app.state.mounted_apps = mounted_apps
2544
+ parent_app.state.is_multi_app = True
2545
+ parent_app.state.engine = engine
2546
+
2547
+ # Store app reference in engine for get_mounted_apps()
2548
+ engine._multi_app_instance = parent_app
2549
+
2244
2550
  # Add request scope middleware
2245
2551
  from starlette.middleware.base import BaseHTTPMiddleware
2246
2552
 
@@ -2629,17 +2935,41 @@ class MongoDBEngine:
2629
2935
  or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
2630
2936
  )
2631
2937
 
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")
2938
+ # Thread-safe initialization with async lock to prevent race conditions
2939
+ async with self._shared_user_pool_lock:
2940
+ # Check if another coroutine is initializing
2941
+ if self._shared_user_pool_initializing:
2942
+ # Wait for other initialization to complete
2943
+ while self._shared_user_pool_initializing:
2944
+ import asyncio
2945
+
2946
+ await asyncio.sleep(0.01) # Small delay to avoid busy-waiting
2947
+ # After waiting, check if pool was initialized
2948
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2949
+ app.state.user_pool = self._shared_user_pool
2950
+ return
2951
+
2952
+ # Check if already initialized (double-check pattern)
2953
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2954
+ app.state.user_pool = self._shared_user_pool
2955
+ return
2640
2956
 
2641
- # Expose user pool on app.state for middleware to access
2642
- app.state.user_pool = self._shared_user_pool
2957
+ # Mark as initializing
2958
+ self._shared_user_pool_initializing = True
2959
+ try:
2960
+ # Create shared user pool
2961
+ self._shared_user_pool = SharedUserPool(
2962
+ self._connection_manager.mongo_db,
2963
+ allow_insecure_dev=is_dev,
2964
+ )
2965
+ await self._shared_user_pool.ensure_indexes()
2966
+ logger.info("SharedUserPool initialized")
2967
+
2968
+ # Expose user pool on app.state for middleware to access
2969
+ app.state.user_pool = self._shared_user_pool
2970
+ finally:
2971
+ # Always clear the initializing flag
2972
+ self._shared_user_pool_initializing = False
2643
2973
 
2644
2974
  # Seed demo users to SharedUserPool if configured in manifest
2645
2975
  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.3
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=Hm7dL74-37z0tXYLcgCV5f6CZ_ZDOaTACYw4N863OCA,3262
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=EA3MlVlzaLjNEzo_l6ZuuRBDoktt80VCrwT2yA33Q5w,133425
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.3.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
+ mdb_engine-0.4.3.dist-info/METADATA,sha256=8CNk129efemfUuZMqe5Jjb6_-L1HLtynLkv4pMbBljw,15810
94
+ mdb_engine-0.4.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
+ mdb_engine-0.4.3.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
+ mdb_engine-0.4.3.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
+ mdb_engine-0.4.3.dist-info/RECORD,,