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 +4 -1
- mdb_engine/auth/shared_middleware.py +162 -32
- mdb_engine/auth/shared_users.py +18 -2
- mdb_engine/core/engine.py +400 -70
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/METADATA +1 -1
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/RECORD +10 -10
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/WHEEL +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.3.dist-info}/top_level.txt +0 -0
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__ =
|
|
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
|
|
253
|
-
# This is
|
|
254
|
-
#
|
|
255
|
-
|
|
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 (
|
|
331
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
685
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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 (
|
|
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
|
-
"""
|
|
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
|
-
|
|
809
|
-
|
|
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,
|
|
971
|
+
app_slug,
|
|
972
|
+
require_role,
|
|
973
|
+
public_routes,
|
|
974
|
+
role_hierarchy,
|
|
975
|
+
session_binding,
|
|
976
|
+
auto_assign_default_role,
|
|
847
977
|
)
|
mdb_engine/auth/shared_users.py
CHANGED
|
@@ -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
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
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
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
#
|
|
2229
|
-
|
|
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
|
-
#
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
-
|
|
2642
|
-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
|
|
2
|
-
mdb_engine/__init__.py,sha256=
|
|
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=
|
|
30
|
-
mdb_engine/auth/shared_users.py,sha256=
|
|
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=
|
|
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.
|
|
93
|
-
mdb_engine-0.4.
|
|
94
|
-
mdb_engine-0.4.
|
|
95
|
-
mdb_engine-0.4.
|
|
96
|
-
mdb_engine-0.4.
|
|
97
|
-
mdb_engine-0.4.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|