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 +4 -1
- mdb_engine/auth/shared_middleware.py +162 -32
- mdb_engine/auth/shared_users.py +18 -2
- mdb_engine/core/engine.py +187 -70
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.dist-info}/METADATA +1 -1
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.dist-info}/RECORD +10 -10
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.dist-info}/WHEEL +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.4.1.dist-info → mdb_engine-0.4.2.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.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
|
|
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.
|
|
@@ -1960,9 +1966,26 @@ class MongoDBEngine:
|
|
|
1960
1966
|
)
|
|
1961
1967
|
|
|
1962
1968
|
# State for parent app
|
|
1963
|
-
mounted_apps
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
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
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
#
|
|
2229
|
-
|
|
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
|
-
#
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
-
|
|
2642
|
-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
|
|
2
|
-
mdb_engine/__init__.py,sha256=
|
|
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=
|
|
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=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.
|
|
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.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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|