mdb-engine 0.4.8__tar.gz → 0.4.10__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.8/mdb_engine.egg-info → mdb_engine-0.4.10}/PKG-INFO +1 -1
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/__init__.py +5 -2
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/csrf.py +44 -3
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/engine.py +248 -74
- {mdb_engine-0.4.8 → mdb_engine-0.4.10/mdb_engine.egg-info}/PKG-INFO +1 -1
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/pyproject.toml +1 -1
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/LICENSE +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/MANIFEST.in +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/ARCHITECTURE.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/audit.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/base.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/casbin_factory.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/casbin_models.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/config_defaults.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/config_helpers.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/cookie_utils.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/decorators.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/dependencies.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/helpers.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/integration.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/jwt.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/middleware.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/oso_factory.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/provider.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/rate_limiter.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/restrictions.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/session_manager.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/shared_middleware.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/shared_users.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/token_lifecycle.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/token_store.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/users.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/auth/utils.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/commands/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/commands/generate.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/commands/migrate.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/commands/show.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/commands/validate.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/main.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/cli/utils.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/config.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/constants.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/app_registration.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/app_secrets.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/connection.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/encryption.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/index_management.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/manifest.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/ray_integration.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/seeding.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/service_initialization.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/core/types.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/abstraction.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/connection.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/query_validator.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/resource_limiter.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/database/scoped_wrapper.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/dependencies.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/di/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/di/container.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/di/providers.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/di/scopes.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/embeddings/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/embeddings/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/embeddings/dependencies.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/embeddings/service.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/exceptions.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/indexes/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/indexes/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/indexes/helpers.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/indexes/manager.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/memory/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/memory/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/memory/service.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/observability/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/observability/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/observability/health.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/observability/logging.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/observability/metrics.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/repositories/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/repositories/base.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/repositories/mongo.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/repositories/unit_of_work.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/routing/README.md +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/routing/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/routing/websockets.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/utils/__init__.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine/utils/mongo.py +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine.egg-info/SOURCES.txt +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine.egg-info/dependency_links.txt +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine.egg-info/entry_points.txt +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine.egg-info/requires.txt +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/mdb_engine.egg-info/top_level.txt +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/setup.cfg +0 -0
- {mdb_engine-0.4.8 → mdb_engine-0.4.10}/setup.py +0 -0
|
@@ -82,8 +82,11 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
|
|
|
82
82
|
from .utils import clean_mongo_doc, clean_mongo_docs
|
|
83
83
|
|
|
84
84
|
__version__ = (
|
|
85
|
-
"0.4.
|
|
86
|
-
#
|
|
85
|
+
"0.4.10" # Fix: allow_credentials preserved in CORS config merge
|
|
86
|
+
# - Fixed bug where allow_credentials was set to False even when child apps require True
|
|
87
|
+
# - Dynamic CORS middleware reads from app.state.cors_config at request time
|
|
88
|
+
# - Merge logic now ensures if ANY child app has allow_credentials: True, parent gets True
|
|
89
|
+
# - Essential for SSO cookie-based authentication in WebSocket connections
|
|
87
90
|
)
|
|
88
91
|
|
|
89
92
|
__all__ = [
|
|
@@ -190,6 +190,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
190
190
|
|
|
191
191
|
def _is_exempt(self, path: str) -> bool:
|
|
192
192
|
"""Check if a path is exempt from CSRF validation."""
|
|
193
|
+
# WebSocket upgrade requests are handled separately in dispatch()
|
|
194
|
+
# Don't exempt them here - they need origin validation
|
|
193
195
|
for pattern in self.exempt_routes:
|
|
194
196
|
if fnmatch.fnmatch(path, pattern):
|
|
195
197
|
return True
|
|
@@ -201,14 +203,42 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
201
203
|
return upgrade_header == "websocket"
|
|
202
204
|
|
|
203
205
|
def _get_allowed_origins(self, request: Request) -> list[str]:
|
|
204
|
-
"""
|
|
206
|
+
"""
|
|
207
|
+
Get allowed origins from app state (CORS config) or use request host as fallback.
|
|
208
|
+
|
|
209
|
+
For multi-app setups, checks parent app's CORS config first (since WebSocket routes
|
|
210
|
+
are registered on parent app), then falls back to request host.
|
|
211
|
+
"""
|
|
205
212
|
try:
|
|
213
|
+
# Check current app's CORS config (parent app for WebSocket routes in multi-app)
|
|
206
214
|
cors_config = getattr(request.app.state, "cors_config", None)
|
|
207
215
|
if cors_config and cors_config.get("allow_origins"):
|
|
208
|
-
|
|
216
|
+
origins = cors_config["allow_origins"]
|
|
217
|
+
if origins:
|
|
218
|
+
return origins if isinstance(origins, list) else [origins]
|
|
209
219
|
except (AttributeError, TypeError, KeyError):
|
|
210
220
|
pass
|
|
211
221
|
|
|
222
|
+
# Fallback: Check if this is a multi-app setup and try to find mounted app's CORS config
|
|
223
|
+
try:
|
|
224
|
+
if hasattr(request.app.state, "mounted_apps"):
|
|
225
|
+
# This is a parent app in multi-app setup
|
|
226
|
+
# Try to find which mounted app this request is for
|
|
227
|
+
path = request.url.path
|
|
228
|
+
mounted_apps = request.app.state.mounted_apps
|
|
229
|
+
|
|
230
|
+
# Find matching mounted app by path prefix
|
|
231
|
+
for app_info in mounted_apps:
|
|
232
|
+
path_prefix = app_info.get("path_prefix", "")
|
|
233
|
+
if path_prefix and path.startswith(path_prefix):
|
|
234
|
+
# Try to get child app's CORS config if available
|
|
235
|
+
# Note: Child app might not be directly accessible, so we rely on
|
|
236
|
+
# parent app's merged CORS config (set during mounting)
|
|
237
|
+
break
|
|
238
|
+
except (AttributeError, TypeError, KeyError):
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# Final fallback: Use request host
|
|
212
242
|
try:
|
|
213
243
|
host = request.url.hostname
|
|
214
244
|
scheme = request.url.scheme
|
|
@@ -247,7 +277,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
247
277
|
|
|
248
278
|
logger.warning(
|
|
249
279
|
f"WebSocket upgrade rejected - invalid Origin: {origin} "
|
|
250
|
-
f"(allowed: {allowed_origins})"
|
|
280
|
+
f"(allowed: {allowed_origins}, app: {getattr(request.app, 'title', 'unknown')}, "
|
|
281
|
+
f"has_cors_config: {hasattr(request.app.state, 'cors_config')})"
|
|
251
282
|
)
|
|
252
283
|
return False
|
|
253
284
|
|
|
@@ -262,12 +293,22 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
262
293
|
path = request.url.path
|
|
263
294
|
method = request.method
|
|
264
295
|
|
|
296
|
+
# CRITICAL: Handle WebSocket upgrade requests BEFORE other CSRF checks
|
|
297
|
+
# WebSocket upgrades don't use CSRF tokens, but need origin validation
|
|
265
298
|
if self._is_websocket_upgrade(request):
|
|
299
|
+
# Validate origin for WebSocket connections (CSWSH protection)
|
|
266
300
|
if not self._validate_websocket_origin(request):
|
|
301
|
+
logger.warning(
|
|
302
|
+
f"WebSocket origin validation failed for {path}: "
|
|
303
|
+
f"origin={request.headers.get('origin')}, "
|
|
304
|
+
f"allowed={self._get_allowed_origins(request)}"
|
|
305
|
+
)
|
|
267
306
|
return JSONResponse(
|
|
268
307
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
269
308
|
content={"detail": "Invalid origin for WebSocket connection"},
|
|
270
309
|
)
|
|
310
|
+
# Origin validated - allow WebSocket upgrade to proceed
|
|
311
|
+
# No CSRF token check needed for WebSocket upgrades
|
|
271
312
|
return await call_next(request)
|
|
272
313
|
|
|
273
314
|
if self._is_exempt(path):
|
|
@@ -2179,6 +2179,130 @@ class MongoDBEngine:
|
|
|
2179
2179
|
return entry
|
|
2180
2180
|
return None
|
|
2181
2181
|
|
|
2182
|
+
async def _merge_cors_config_to_parent(
|
|
2183
|
+
parent_app: "FastAPI",
|
|
2184
|
+
child_app: "FastAPI",
|
|
2185
|
+
child_manifest: dict[str, Any],
|
|
2186
|
+
slug: str,
|
|
2187
|
+
) -> None:
|
|
2188
|
+
"""Merge CORS config from child app to parent app."""
|
|
2189
|
+
child_cors = None
|
|
2190
|
+
if hasattr(child_app.state, "cors_config"):
|
|
2191
|
+
child_cors = child_app.state.cors_config
|
|
2192
|
+
else:
|
|
2193
|
+
# CORS config might not be set yet (lifespan runs asynchronously)
|
|
2194
|
+
# Get it from manifest directly
|
|
2195
|
+
cors_config_from_manifest = child_manifest.get("cors", {})
|
|
2196
|
+
if cors_config_from_manifest:
|
|
2197
|
+
from ..auth.config_helpers import (
|
|
2198
|
+
CORS_DEFAULTS,
|
|
2199
|
+
merge_config_with_defaults,
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
child_cors = merge_config_with_defaults(
|
|
2203
|
+
cors_config_from_manifest, CORS_DEFAULTS
|
|
2204
|
+
)
|
|
2205
|
+
# Also set it on child app state for future reference
|
|
2206
|
+
child_app.state.cors_config = child_cors
|
|
2207
|
+
|
|
2208
|
+
if child_cors:
|
|
2209
|
+
if hasattr(parent_app.state, "cors_config"):
|
|
2210
|
+
# Merge child CORS into parent (child takes precedence for its routes)
|
|
2211
|
+
parent_cors = parent_app.state.cors_config
|
|
2212
|
+
# Merge allow_origins lists
|
|
2213
|
+
child_origins = child_cors.get("allow_origins", [])
|
|
2214
|
+
parent_origins = parent_cors.get("allow_origins", [])
|
|
2215
|
+
merged_origins = list(set(parent_origins + child_origins))
|
|
2216
|
+
|
|
2217
|
+
# CRITICAL: If ANY child app requires credentials, parent must allow them
|
|
2218
|
+
# This is essential for SSO cookie-based authentication
|
|
2219
|
+
child_requires_credentials = child_cors.get("allow_credentials", False)
|
|
2220
|
+
parent_allows_credentials = parent_cors.get("allow_credentials", False)
|
|
2221
|
+
merged_allow_credentials = (
|
|
2222
|
+
child_requires_credentials or parent_allows_credentials
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
parent_app.state.cors_config = {
|
|
2226
|
+
**parent_cors,
|
|
2227
|
+
**child_cors,
|
|
2228
|
+
"allow_origins": merged_origins if merged_origins else ["*"],
|
|
2229
|
+
# If ANY child requires credentials, parent gets True (for SSO)
|
|
2230
|
+
"allow_credentials": merged_allow_credentials,
|
|
2231
|
+
}
|
|
2232
|
+
else:
|
|
2233
|
+
# Parent has no CORS config, use child's
|
|
2234
|
+
parent_app.state.cors_config = child_cors
|
|
2235
|
+
logger.debug(
|
|
2236
|
+
f"✅ Merged CORS config from child app '{slug}' to parent app "
|
|
2237
|
+
f"(allow_credentials={parent_app.state.cors_config.get('allow_credentials')})"
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
async def _register_websocket_routes(
|
|
2241
|
+
parent_app: "FastAPI",
|
|
2242
|
+
child_manifest: dict[str, Any],
|
|
2243
|
+
slug: str,
|
|
2244
|
+
path_prefix: str,
|
|
2245
|
+
) -> None:
|
|
2246
|
+
"""Register WebSocket routes on parent app for a child app."""
|
|
2247
|
+
websockets_config = child_manifest.get("websockets")
|
|
2248
|
+
if not websockets_config:
|
|
2249
|
+
return
|
|
2250
|
+
|
|
2251
|
+
try:
|
|
2252
|
+
from fastapi import APIRouter
|
|
2253
|
+
|
|
2254
|
+
from ..routing.websockets import create_websocket_endpoint
|
|
2255
|
+
|
|
2256
|
+
for endpoint_name, endpoint_config in websockets_config.items():
|
|
2257
|
+
ws_path = endpoint_config.get("path", f"/{endpoint_name}")
|
|
2258
|
+
# Combine mount prefix with WebSocket path
|
|
2259
|
+
full_ws_path = f"{path_prefix.rstrip('/')}{ws_path}"
|
|
2260
|
+
|
|
2261
|
+
# Handle auth configuration
|
|
2262
|
+
auth_config = endpoint_config.get("auth", {})
|
|
2263
|
+
if isinstance(auth_config, dict) and "required" in auth_config:
|
|
2264
|
+
require_auth = auth_config.get("required", True)
|
|
2265
|
+
elif "require_auth" in endpoint_config:
|
|
2266
|
+
require_auth = endpoint_config.get("require_auth", True)
|
|
2267
|
+
else:
|
|
2268
|
+
# Use app's auth_policy if available
|
|
2269
|
+
if "auth_policy" in child_manifest:
|
|
2270
|
+
require_auth = child_manifest["auth_policy"].get("required", True)
|
|
2271
|
+
else:
|
|
2272
|
+
require_auth = True
|
|
2273
|
+
|
|
2274
|
+
ping_interval = endpoint_config.get("ping_interval", 30)
|
|
2275
|
+
|
|
2276
|
+
# Create WebSocket handler
|
|
2277
|
+
handler = create_websocket_endpoint(
|
|
2278
|
+
app_slug=slug,
|
|
2279
|
+
path=ws_path,
|
|
2280
|
+
endpoint_name=endpoint_name,
|
|
2281
|
+
handler=None,
|
|
2282
|
+
require_auth=require_auth,
|
|
2283
|
+
ping_interval=ping_interval,
|
|
2284
|
+
)
|
|
2285
|
+
|
|
2286
|
+
# Register on parent app with full path
|
|
2287
|
+
ws_router = APIRouter()
|
|
2288
|
+
ws_router.websocket(full_ws_path)(handler)
|
|
2289
|
+
parent_app.include_router(ws_router)
|
|
2290
|
+
|
|
2291
|
+
logger.info(
|
|
2292
|
+
f"✅ Registered WebSocket route '{full_ws_path}' "
|
|
2293
|
+
f"for mounted app '{slug}' (mounted at '{path_prefix}')"
|
|
2294
|
+
)
|
|
2295
|
+
except ImportError:
|
|
2296
|
+
logger.warning(
|
|
2297
|
+
f"WebSocket support not available - skipping WebSocket routes "
|
|
2298
|
+
f"for mounted app '{slug}'"
|
|
2299
|
+
)
|
|
2300
|
+
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
2301
|
+
logger.error(
|
|
2302
|
+
f"Failed to register WebSocket routes for mounted app '{slug}': {e}",
|
|
2303
|
+
exc_info=True,
|
|
2304
|
+
)
|
|
2305
|
+
|
|
2182
2306
|
@asynccontextmanager
|
|
2183
2307
|
async def lifespan(app: FastAPI):
|
|
2184
2308
|
"""Lifespan context manager for parent app."""
|
|
@@ -2376,71 +2500,11 @@ class MongoDBEngine:
|
|
|
2376
2500
|
# Mount child app at path prefix
|
|
2377
2501
|
app.mount(path_prefix, child_app)
|
|
2378
2502
|
|
|
2379
|
-
# CRITICAL FIX:
|
|
2380
|
-
|
|
2381
|
-
# so we need to register them on the parent app with the mount prefix
|
|
2382
|
-
# Get WebSocket config from manifest directly (app registration happens
|
|
2383
|
-
# asynchronously in lifespan, so config may not be available yet)
|
|
2384
|
-
websockets_config = app_manifest_data.get("websockets")
|
|
2385
|
-
if websockets_config:
|
|
2386
|
-
try:
|
|
2387
|
-
from fastapi import APIRouter
|
|
2388
|
-
|
|
2389
|
-
from ..routing.websockets import create_websocket_endpoint
|
|
2390
|
-
|
|
2391
|
-
for endpoint_name, endpoint_config in websockets_config.items():
|
|
2392
|
-
ws_path = endpoint_config.get("path", f"/{endpoint_name}")
|
|
2393
|
-
# Combine mount prefix with WebSocket path
|
|
2394
|
-
full_ws_path = f"{path_prefix.rstrip('/')}{ws_path}"
|
|
2395
|
-
|
|
2396
|
-
# Handle auth configuration
|
|
2397
|
-
auth_config = endpoint_config.get("auth", {})
|
|
2398
|
-
if isinstance(auth_config, dict) and "required" in auth_config:
|
|
2399
|
-
require_auth = auth_config.get("required", True)
|
|
2400
|
-
elif "require_auth" in endpoint_config:
|
|
2401
|
-
require_auth = endpoint_config.get("require_auth", True)
|
|
2402
|
-
else:
|
|
2403
|
-
# Use app's auth_policy if available
|
|
2404
|
-
if "auth_policy" in app_manifest_data:
|
|
2405
|
-
require_auth = app_manifest_data["auth_policy"].get(
|
|
2406
|
-
"required", True
|
|
2407
|
-
)
|
|
2408
|
-
else:
|
|
2409
|
-
require_auth = True
|
|
2410
|
-
|
|
2411
|
-
ping_interval = endpoint_config.get("ping_interval", 30)
|
|
2412
|
-
|
|
2413
|
-
# Create WebSocket handler
|
|
2414
|
-
# Use original path for handler (mount handled internally)
|
|
2415
|
-
handler = create_websocket_endpoint(
|
|
2416
|
-
app_slug=slug,
|
|
2417
|
-
path=ws_path,
|
|
2418
|
-
endpoint_name=endpoint_name,
|
|
2419
|
-
handler=None,
|
|
2420
|
-
require_auth=require_auth,
|
|
2421
|
-
ping_interval=ping_interval,
|
|
2422
|
-
)
|
|
2503
|
+
# CRITICAL FIX: Merge CORS config from child app to parent app
|
|
2504
|
+
await _merge_cors_config_to_parent(app, child_app, app_manifest_data, slug)
|
|
2423
2505
|
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
ws_router.websocket(full_ws_path)(handler)
|
|
2427
|
-
app.include_router(ws_router)
|
|
2428
|
-
|
|
2429
|
-
logger.info(
|
|
2430
|
-
f"✅ Registered WebSocket route '{full_ws_path}' "
|
|
2431
|
-
f"for mounted app '{slug}' (mounted at '{path_prefix}')"
|
|
2432
|
-
)
|
|
2433
|
-
except ImportError:
|
|
2434
|
-
logger.warning(
|
|
2435
|
-
f"WebSocket support not available - skipping WebSocket routes "
|
|
2436
|
-
f"for mounted app '{slug}'"
|
|
2437
|
-
)
|
|
2438
|
-
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
2439
|
-
logger.error(
|
|
2440
|
-
f"Failed to register WebSocket routes for mounted app "
|
|
2441
|
-
f"'{slug}': {e}",
|
|
2442
|
-
exc_info=True,
|
|
2443
|
-
)
|
|
2506
|
+
# CRITICAL FIX: Register WebSocket routes on parent app with full path
|
|
2507
|
+
await _register_websocket_routes(app, app_manifest_data, slug, path_prefix)
|
|
2444
2508
|
|
|
2445
2509
|
# Update existing entry instead of appending
|
|
2446
2510
|
entry = _find_mounted_app_entry(slug)
|
|
@@ -2617,6 +2681,19 @@ class MongoDBEngine:
|
|
|
2617
2681
|
parent_app.state.is_multi_app = True
|
|
2618
2682
|
parent_app.state.engine = engine
|
|
2619
2683
|
|
|
2684
|
+
# Set default CORS config on parent app for WebSocket origin validation
|
|
2685
|
+
# This ensures CSRF middleware can validate WebSocket origins even if child apps
|
|
2686
|
+
# don't configure CORS
|
|
2687
|
+
# NOTE: allow_credentials defaults to False here, but will be set to True
|
|
2688
|
+
# during merge if any child app requires it (essential for SSO cookie-based auth)
|
|
2689
|
+
from ..auth.config_helpers import CORS_DEFAULTS
|
|
2690
|
+
|
|
2691
|
+
parent_app.state.cors_config = CORS_DEFAULTS.copy()
|
|
2692
|
+
parent_app.state.cors_config["enabled"] = True
|
|
2693
|
+
parent_app.state.cors_config["allow_origins"] = ["*"] # Default to allow all for WebSocket
|
|
2694
|
+
# Keep allow_credentials as False initially - will be merged from child apps
|
|
2695
|
+
logger.debug("Set default CORS config on parent app for WebSocket origin validation")
|
|
2696
|
+
|
|
2620
2697
|
# Store app reference in engine for get_mounted_apps()
|
|
2621
2698
|
engine._multi_app_instance = parent_app
|
|
2622
2699
|
|
|
@@ -2639,19 +2716,116 @@ class MongoDBEngine:
|
|
|
2639
2716
|
parent_app.add_middleware(RequestScopeMiddleware)
|
|
2640
2717
|
logger.debug("RequestScopeMiddleware added for parent app")
|
|
2641
2718
|
|
|
2719
|
+
# CRITICAL: Add CSRF middleware to parent app if any child app uses shared auth
|
|
2720
|
+
# WebSocket routes are registered on parent app, so parent app middleware runs first
|
|
2721
|
+
# CSRF middleware on parent app validates WebSocket origin using parent app's CORS config
|
|
2722
|
+
if has_shared_auth:
|
|
2723
|
+
from ..auth.csrf import create_csrf_middleware
|
|
2724
|
+
|
|
2725
|
+
# Create CSRF middleware with default config (will use parent app's CORS config)
|
|
2726
|
+
# Exempt routes that don't need CSRF (health checks, etc.)
|
|
2727
|
+
parent_csrf_config = {
|
|
2728
|
+
"csrf_protection": True,
|
|
2729
|
+
"public_routes": ["/health", "/docs", "/openapi.json", "/_mdb/routes"],
|
|
2730
|
+
}
|
|
2731
|
+
csrf_middleware = create_csrf_middleware(parent_csrf_config)
|
|
2732
|
+
parent_app.add_middleware(csrf_middleware)
|
|
2733
|
+
logger.info("CSRFMiddleware added to parent app for WebSocket origin validation")
|
|
2734
|
+
|
|
2642
2735
|
# Add shared CORS middleware if configured
|
|
2643
|
-
#
|
|
2736
|
+
# NOTE: We create a dynamic CORS middleware that reads from app.state.cors_config
|
|
2737
|
+
# This allows the config to be updated after child apps are mounted and merged
|
|
2644
2738
|
try:
|
|
2645
|
-
from
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2739
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2740
|
+
from starlette.requests import Request
|
|
2741
|
+
from starlette.responses import Response
|
|
2742
|
+
|
|
2743
|
+
class DynamicCORSMiddleware(BaseHTTPMiddleware):
|
|
2744
|
+
"""
|
|
2745
|
+
Dynamic CORS middleware that reads config from app.state.cors_config.
|
|
2746
|
+
|
|
2747
|
+
This allows CORS config to be updated after child apps are mounted
|
|
2748
|
+
and their configs are merged, which is essential for SSO multi-app
|
|
2749
|
+
setups where allow_credentials must be True for cookie-based auth.
|
|
2750
|
+
"""
|
|
2751
|
+
|
|
2752
|
+
async def dispatch(self, request: Request, call_next):
|
|
2753
|
+
# Read CORS config from app.state (may have been merged from child apps)
|
|
2754
|
+
cors_config = getattr(request.app.state, "cors_config", {})
|
|
2755
|
+
|
|
2756
|
+
if not cors_config.get("enabled", False):
|
|
2757
|
+
# CORS not enabled, pass through
|
|
2758
|
+
return await call_next(request)
|
|
2759
|
+
|
|
2760
|
+
# Handle preflight OPTIONS request
|
|
2761
|
+
if request.method == "OPTIONS":
|
|
2762
|
+
origin = request.headers.get("origin")
|
|
2763
|
+
allowed_origins = cors_config.get("allow_origins", ["*"])
|
|
2764
|
+
allow_credentials = cors_config.get("allow_credentials", False)
|
|
2765
|
+
|
|
2766
|
+
# Check if origin is allowed
|
|
2767
|
+
origin_allowed = False
|
|
2768
|
+
if "*" in allowed_origins:
|
|
2769
|
+
origin_allowed = True
|
|
2770
|
+
elif origin in allowed_origins:
|
|
2771
|
+
origin_allowed = True
|
|
2772
|
+
|
|
2773
|
+
if origin_allowed:
|
|
2774
|
+
headers = {
|
|
2775
|
+
"Access-Control-Allow-Methods": ", ".join(
|
|
2776
|
+
cors_config.get("allow_methods", ["*"])
|
|
2777
|
+
),
|
|
2778
|
+
"Access-Control-Allow-Headers": ", ".join(
|
|
2779
|
+
cors_config.get("allow_headers", ["*"])
|
|
2780
|
+
),
|
|
2781
|
+
"Access-Control-Max-Age": str(cors_config.get("max_age", 3600)),
|
|
2782
|
+
}
|
|
2783
|
+
if allow_credentials:
|
|
2784
|
+
headers["Access-Control-Allow-Credentials"] = "true"
|
|
2785
|
+
if origin:
|
|
2786
|
+
headers["Access-Control-Allow-Origin"] = origin
|
|
2787
|
+
|
|
2788
|
+
expose_headers = cors_config.get("expose_headers", [])
|
|
2789
|
+
if expose_headers:
|
|
2790
|
+
headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers)
|
|
2791
|
+
|
|
2792
|
+
return Response(status_code=200, headers=headers)
|
|
2793
|
+
else:
|
|
2794
|
+
return Response(status_code=403)
|
|
2795
|
+
|
|
2796
|
+
# Handle actual request
|
|
2797
|
+
response = await call_next(request)
|
|
2798
|
+
|
|
2799
|
+
# Add CORS headers to response
|
|
2800
|
+
origin = request.headers.get("origin")
|
|
2801
|
+
allowed_origins = cors_config.get("allow_origins", ["*"])
|
|
2802
|
+
allow_credentials = cors_config.get("allow_credentials", False)
|
|
2803
|
+
|
|
2804
|
+
# Check if origin is allowed
|
|
2805
|
+
origin_allowed = False
|
|
2806
|
+
if "*" in allowed_origins:
|
|
2807
|
+
origin_allowed = True
|
|
2808
|
+
elif origin and origin in allowed_origins:
|
|
2809
|
+
origin_allowed = True
|
|
2810
|
+
|
|
2811
|
+
if origin_allowed:
|
|
2812
|
+
if origin:
|
|
2813
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
2814
|
+
if allow_credentials:
|
|
2815
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
2816
|
+
|
|
2817
|
+
expose_headers = cors_config.get("expose_headers", [])
|
|
2818
|
+
if expose_headers:
|
|
2819
|
+
response.headers["Access-Control-Expose-Headers"] = ", ".join(
|
|
2820
|
+
expose_headers
|
|
2821
|
+
)
|
|
2822
|
+
|
|
2823
|
+
return response
|
|
2824
|
+
|
|
2825
|
+
parent_app.add_middleware(DynamicCORSMiddleware)
|
|
2826
|
+
logger.debug(
|
|
2827
|
+
"Dynamic CORS middleware added for parent app (reads from app.state.cors_config)"
|
|
2653
2828
|
)
|
|
2654
|
-
logger.debug("CORS middleware added for parent app")
|
|
2655
2829
|
except ImportError:
|
|
2656
2830
|
logger.warning("CORS middleware not available")
|
|
2657
2831
|
|
|
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
|
|
File without changes
|
|
File without changes
|