mdb-engine 0.4.0__tar.gz → 0.4.2__tar.gz
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-0.4.0/mdb_engine.egg-info → mdb_engine-0.4.2}/PKG-INFO +1 -1
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/__init__.py +4 -1
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/shared_middleware.py +185 -36
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/shared_users.py +18 -2
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/engine.py +187 -70
- {mdb_engine-0.4.0 → mdb_engine-0.4.2/mdb_engine.egg-info}/PKG-INFO +1 -1
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/pyproject.toml +1 -1
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/setup.py +1 -1
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/LICENSE +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/MANIFEST.in +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/ARCHITECTURE.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/audit.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/base.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/casbin_factory.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/casbin_models.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/config_defaults.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/config_helpers.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/cookie_utils.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/csrf.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/decorators.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/dependencies.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/helpers.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/integration.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/jwt.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/middleware.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/oso_factory.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/provider.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/rate_limiter.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/restrictions.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/session_manager.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/token_lifecycle.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/token_store.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/users.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/utils.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/generate.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/migrate.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/show.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/validate.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/main.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/utils.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/config.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/constants.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/app_registration.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/app_secrets.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/connection.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/encryption.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/index_management.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/manifest.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/ray_integration.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/seeding.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/service_initialization.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/types.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/abstraction.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/connection.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/query_validator.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/resource_limiter.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/scoped_wrapper.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/dependencies.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/container.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/providers.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/scopes.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/dependencies.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/service.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/exceptions.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/helpers.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/manager.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/service.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/health.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/logging.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/metrics.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/base.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/mongo.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/unit_of_work.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/README.md +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/websockets.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/utils/__init__.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/utils/mongo.py +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/SOURCES.txt +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/dependency_links.txt +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/entry_points.txt +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/requires.txt +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/top_level.txt +0 -0
- {mdb_engine-0.4.0 → mdb_engine-0.4.2}/setup.cfg +0 -0
|
@@ -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
|
|
@@ -86,16 +86,95 @@ def _get_request_path(request: Request) -> str:
|
|
|
86
86
|
"""
|
|
87
87
|
Get the request path relative to the mount point.
|
|
88
88
|
|
|
89
|
-
For mounted apps (via create_multi_app),
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
For mounted apps (via create_multi_app), strips the path prefix from
|
|
90
|
+
request.url.path using request.state.app_base_path. For non-mounted apps,
|
|
91
|
+
uses request.scope["path"] if available, otherwise falls back to request.url.path.
|
|
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.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# Check if this is a mounted app with a path prefix
|
|
100
|
+
app_base_path = getattr(request.state, "app_base_path", None)
|
|
101
|
+
# Ensure app_base_path is a string (not a MagicMock in tests)
|
|
102
|
+
if app_base_path and isinstance(app_base_path, str):
|
|
103
|
+
# Ensure request.url.path is a string before calling startswith
|
|
104
|
+
url_path = str(request.url.path) if hasattr(request.url, "path") else None
|
|
105
|
+
if url_path and url_path.startswith(app_base_path):
|
|
106
|
+
# Strip the path prefix to get relative path
|
|
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)
|
|
110
|
+
# Ensure path starts with / (handle case where prefix is entire path)
|
|
111
|
+
return relative_path if relative_path else "/"
|
|
112
|
+
|
|
113
|
+
# Fall back to scope["path"] for mounted apps (if available)
|
|
114
|
+
# This handles cases where Starlette/FastAPI sets it correctly
|
|
115
|
+
if "path" in request.scope:
|
|
116
|
+
return _normalize_path(request.scope["path"])
|
|
117
|
+
|
|
118
|
+
# Default to url.path for non-mounted apps
|
|
119
|
+
# Ensure we return a string
|
|
120
|
+
if hasattr(request.url, "path"):
|
|
121
|
+
return _normalize_path(str(request.url.path))
|
|
122
|
+
return "/"
|
|
123
|
+
|
|
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 /
|
|
95
134
|
"""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
99
178
|
|
|
100
179
|
|
|
101
180
|
class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
@@ -123,6 +202,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
123
202
|
public_routes: list[str] | None = None,
|
|
124
203
|
role_hierarchy: dict[str, list[str]] | None = None,
|
|
125
204
|
session_binding: dict[str, Any] | None = None,
|
|
205
|
+
auto_assign_default_role: bool = False,
|
|
126
206
|
cookie_name: str = AUTH_COOKIE_NAME,
|
|
127
207
|
header_name: str = AUTH_HEADER_NAME,
|
|
128
208
|
header_prefix: str = AUTH_HEADER_PREFIX,
|
|
@@ -142,6 +222,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
142
222
|
- bind_ip: Strict - reject if IP changes
|
|
143
223
|
- bind_fingerprint: Soft - log warning if fingerprint changes
|
|
144
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.
|
|
145
229
|
cookie_name: Name of auth cookie (default: mdb_auth_token)
|
|
146
230
|
header_name: Name of auth header (default: Authorization)
|
|
147
231
|
header_prefix: Prefix for header value (default: "Bearer ")
|
|
@@ -153,6 +237,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
153
237
|
self._public_routes = public_routes or []
|
|
154
238
|
self._role_hierarchy = role_hierarchy
|
|
155
239
|
self._session_binding = session_binding or {}
|
|
240
|
+
self._auto_assign_default_role = auto_assign_default_role
|
|
156
241
|
self._cookie_name = cookie_name
|
|
157
242
|
self._header_name = header_name
|
|
158
243
|
self._header_prefix = header_prefix
|
|
@@ -230,11 +315,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
230
315
|
)
|
|
231
316
|
|
|
232
317
|
if not has_required_role:
|
|
233
|
-
# Auto-assign required role if user has no roles
|
|
234
|
-
# This is
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
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:
|
|
238
322
|
user_email = user.get("email")
|
|
239
323
|
if user_email:
|
|
240
324
|
try:
|
|
@@ -250,7 +334,8 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
250
334
|
request.state.user_roles = [self._require_role]
|
|
251
335
|
logger.info(
|
|
252
336
|
f"Auto-assigned role '{self._require_role}' to user "
|
|
253
|
-
f"{user_email} for app '{self._app_slug}'"
|
|
337
|
+
f"{user_email} for app '{self._app_slug}' "
|
|
338
|
+
f"(auto_assign_default_role enabled)"
|
|
254
339
|
)
|
|
255
340
|
else:
|
|
256
341
|
logger.warning(
|
|
@@ -308,17 +393,29 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
308
393
|
logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
|
|
309
394
|
return "Session bound to different IP address"
|
|
310
395
|
|
|
311
|
-
# Check fingerprint binding (
|
|
312
|
-
|
|
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:
|
|
313
402
|
token_fp = payload.get("fp")
|
|
314
403
|
if token_fp:
|
|
315
404
|
client_fp = _compute_fingerprint(request)
|
|
316
405
|
if client_fp != token_fp:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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)
|
|
322
419
|
|
|
323
420
|
return None
|
|
324
421
|
|
|
@@ -402,6 +499,17 @@ def create_shared_auth_middleware(
|
|
|
402
499
|
"""
|
|
403
500
|
require_role = manifest_auth.get("require_role")
|
|
404
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
|
|
405
513
|
|
|
406
514
|
# Build role hierarchy from manifest if available
|
|
407
515
|
role_hierarchy = None
|
|
@@ -424,6 +532,7 @@ def create_shared_auth_middleware(
|
|
|
424
532
|
require_role=require_role,
|
|
425
533
|
public_routes=public_routes,
|
|
426
534
|
role_hierarchy=role_hierarchy,
|
|
535
|
+
auto_assign_default_role=auto_assign_default_role,
|
|
427
536
|
)
|
|
428
537
|
|
|
429
538
|
return ConfiguredSharedAuthMiddleware
|
|
@@ -484,12 +593,13 @@ def _extract_token_helper(
|
|
|
484
593
|
return None
|
|
485
594
|
|
|
486
595
|
|
|
487
|
-
def _create_lazy_middleware_class(
|
|
596
|
+
def _create_lazy_middleware_class( # noqa: C901
|
|
488
597
|
app_slug: str,
|
|
489
598
|
require_role: str | None,
|
|
490
599
|
public_routes: list[str],
|
|
491
600
|
role_hierarchy: dict[str, list[str]] | None,
|
|
492
601
|
session_binding: dict[str, Any],
|
|
602
|
+
auto_assign_default_role: bool = False,
|
|
493
603
|
) -> type:
|
|
494
604
|
"""Create the LazySharedAuthMiddleware class with configuration."""
|
|
495
605
|
|
|
@@ -508,6 +618,7 @@ def _create_lazy_middleware_class(
|
|
|
508
618
|
self._public_routes = public_routes
|
|
509
619
|
self._role_hierarchy = role_hierarchy
|
|
510
620
|
self._session_binding = session_binding
|
|
621
|
+
self._auto_assign_default_role = auto_assign_default_role
|
|
511
622
|
self._cookie_name = AUTH_COOKIE_NAME
|
|
512
623
|
self._header_name = AUTH_HEADER_NAME
|
|
513
624
|
self._header_prefix = AUTH_HEADER_PREFIX
|
|
@@ -662,8 +773,9 @@ def _create_lazy_middleware_class(
|
|
|
662
773
|
if has_required_role:
|
|
663
774
|
return None
|
|
664
775
|
|
|
665
|
-
# Auto-assign required role if user has no roles
|
|
666
|
-
|
|
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:
|
|
667
779
|
await self._try_auto_assign_role(user, user_pool, request)
|
|
668
780
|
|
|
669
781
|
# Check again after potential auto-assignment
|
|
@@ -688,10 +800,8 @@ def _create_lazy_middleware_class(
|
|
|
688
800
|
"""
|
|
689
801
|
Attempt to auto-assign required role to user.
|
|
690
802
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
they have other roles but not the required one - prevents privilege
|
|
694
|
-
escalation).
|
|
803
|
+
SECURITY: Only called if auto_assign_default_role is enabled and user has
|
|
804
|
+
no roles. This prevents privilege escalation.
|
|
695
805
|
"""
|
|
696
806
|
user_email = user.get("email")
|
|
697
807
|
if not user_email:
|
|
@@ -710,7 +820,8 @@ def _create_lazy_middleware_class(
|
|
|
710
820
|
request.state.user_roles = [self._require_role]
|
|
711
821
|
logger.info(
|
|
712
822
|
f"Auto-assigned role '{self._require_role}' to user "
|
|
713
|
-
f"{user_email} for app '{self._app_slug}'"
|
|
823
|
+
f"{user_email} for app '{self._app_slug}' "
|
|
824
|
+
f"(auto_assign_default_role enabled)"
|
|
714
825
|
)
|
|
715
826
|
else:
|
|
716
827
|
logger.warning(
|
|
@@ -750,8 +861,10 @@ def _create_lazy_middleware_class(
|
|
|
750
861
|
if ip_error:
|
|
751
862
|
return ip_error
|
|
752
863
|
|
|
753
|
-
# Check fingerprint binding (
|
|
754
|
-
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
|
|
755
868
|
|
|
756
869
|
return None
|
|
757
870
|
|
|
@@ -775,19 +888,38 @@ def _create_lazy_middleware_class(
|
|
|
775
888
|
|
|
776
889
|
return None
|
|
777
890
|
|
|
778
|
-
def _check_fingerprint_binding(self, request: Request, payload: dict) -> None:
|
|
779
|
-
"""
|
|
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
|
+
"""
|
|
780
897
|
if not self._session_binding.get("bind_fingerprint", True):
|
|
781
|
-
return
|
|
898
|
+
return None
|
|
782
899
|
|
|
783
900
|
token_fp = payload.get("fp")
|
|
784
901
|
if not token_fp:
|
|
785
|
-
return
|
|
902
|
+
return None
|
|
786
903
|
|
|
904
|
+
strict_fingerprint = self._session_binding.get(
|
|
905
|
+
"strict_fingerprint", True
|
|
906
|
+
) # Default: strict
|
|
787
907
|
client_fp = _compute_fingerprint(request)
|
|
788
908
|
if client_fp != token_fp:
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
791
923
|
|
|
792
924
|
return LazySharedAuthMiddleware
|
|
793
925
|
|
|
@@ -820,9 +952,26 @@ def create_shared_auth_middleware_lazy(
|
|
|
820
952
|
"""
|
|
821
953
|
require_role = manifest_auth.get("require_role")
|
|
822
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
|
+
|
|
823
967
|
role_hierarchy = _build_role_hierarchy(manifest_auth)
|
|
824
968
|
session_binding = manifest_auth.get("session_binding", {})
|
|
825
969
|
|
|
826
970
|
return _create_lazy_middleware_class(
|
|
827
|
-
app_slug,
|
|
971
|
+
app_slug,
|
|
972
|
+
require_role,
|
|
973
|
+
public_routes,
|
|
974
|
+
role_hierarchy,
|
|
975
|
+
session_binding,
|
|
976
|
+
auto_assign_default_role,
|
|
828
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
|
|
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,
|
|
@@ -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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|