mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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 +116 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +654 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +265 -70
- mdb_engine/auth/config_defaults.py +5 -5
- mdb_engine/auth/config_helpers.py +19 -18
- mdb_engine/auth/cookie_utils.py +12 -16
- mdb_engine/auth/csrf.py +483 -0
- mdb_engine/auth/decorators.py +10 -16
- mdb_engine/auth/dependencies.py +69 -71
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +61 -88
- mdb_engine/auth/jwt.py +11 -15
- mdb_engine/auth/middleware.py +79 -35
- mdb_engine/auth/oso_factory.py +21 -41
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +505 -0
- mdb_engine/auth/restrictions.py +21 -36
- mdb_engine/auth/session_manager.py +24 -41
- mdb_engine/auth/shared_middleware.py +977 -0
- mdb_engine/auth/shared_users.py +775 -0
- mdb_engine/auth/token_lifecycle.py +10 -12
- mdb_engine/auth/token_store.py +17 -32
- mdb_engine/auth/users.py +99 -159
- mdb_engine/auth/utils.py +236 -42
- mdb_engine/cli/commands/generate.py +546 -10
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +7 -7
- mdb_engine/config.py +13 -28
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +31 -50
- mdb_engine/core/app_secrets.py +289 -0
- mdb_engine/core/connection.py +20 -12
- mdb_engine/core/encryption.py +222 -0
- mdb_engine/core/engine.py +2862 -115
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +628 -204
- mdb_engine/core/ray_integration.py +436 -0
- mdb_engine/core/seeding.py +13 -21
- mdb_engine/core/service_initialization.py +20 -30
- mdb_engine/core/types.py +40 -43
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +37 -50
- mdb_engine/database/connection.py +51 -30
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +747 -237
- mdb_engine/dependencies.py +427 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +247 -0
- mdb_engine/di/providers.py +206 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +38 -155
- mdb_engine/embeddings/service.py +78 -75
- mdb_engine/exceptions.py +104 -12
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +11 -11
- mdb_engine/indexes/manager.py +59 -123
- mdb_engine/memory/README.md +95 -4
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +363 -1168
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +17 -17
- mdb_engine/observability/logging.py +10 -10
- mdb_engine/observability/metrics.py +40 -19
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +41 -75
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- mdb_engine-0.4.12.dist-info/METADATA +492 -0
- mdb_engine-0.4.12.dist-info/RECORD +97 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
mdb_engine/core/engine.py
CHANGED
|
@@ -7,28 +7,55 @@ The core orchestration engine for MDB_ENGINE that manages:
|
|
|
7
7
|
- Authentication/authorization
|
|
8
8
|
- Index management
|
|
9
9
|
- Resource lifecycle
|
|
10
|
+
- Optional Ray integration for distributed processing
|
|
11
|
+
- FastAPI integration with lifespan management
|
|
10
12
|
|
|
11
13
|
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
# Simple usage (most common)
|
|
17
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
18
|
+
await engine.initialize()
|
|
19
|
+
db = engine.get_scoped_db("my_app")
|
|
20
|
+
|
|
21
|
+
# With FastAPI integration
|
|
22
|
+
app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
|
|
23
|
+
|
|
24
|
+
# With Ray support (optional)
|
|
25
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=..., enable_ray=True)
|
|
12
26
|
"""
|
|
13
27
|
|
|
14
28
|
import logging
|
|
29
|
+
import os
|
|
30
|
+
import secrets
|
|
31
|
+
from collections.abc import Awaitable, Callable
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
15
33
|
from pathlib import Path
|
|
16
|
-
from typing import TYPE_CHECKING, Any,
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
17
35
|
|
|
18
|
-
from motor.motor_asyncio import AsyncIOMotorClient
|
|
36
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
37
|
+
from pymongo.errors import PyMongoError
|
|
19
38
|
|
|
20
39
|
if TYPE_CHECKING:
|
|
40
|
+
from fastapi import FastAPI
|
|
41
|
+
|
|
21
42
|
from ..auth import AuthorizationProvider
|
|
22
43
|
from .types import ManifestDict
|
|
23
44
|
|
|
24
45
|
# Import engine components
|
|
25
46
|
from ..constants import DEFAULT_MAX_POOL_SIZE, DEFAULT_MIN_POOL_SIZE
|
|
26
47
|
from ..database import ScopedMongoWrapper
|
|
27
|
-
from ..observability import (
|
|
28
|
-
|
|
48
|
+
from ..observability import (
|
|
49
|
+
HealthChecker,
|
|
50
|
+
check_engine_health,
|
|
51
|
+
check_mongodb_health,
|
|
52
|
+
check_pool_health,
|
|
53
|
+
)
|
|
29
54
|
from ..observability import get_logger as get_contextual_logger
|
|
30
55
|
from .app_registration import AppRegistrationManager
|
|
56
|
+
from .app_secrets import AppSecretsManager
|
|
31
57
|
from .connection import ConnectionManager
|
|
58
|
+
from .encryption import EnvelopeEncryptionService
|
|
32
59
|
from .index_management import IndexManager
|
|
33
60
|
from .manifest import ManifestParser, ManifestValidator
|
|
34
61
|
from .service_initialization import ServiceInitializer
|
|
@@ -48,16 +75,33 @@ class MongoDBEngine:
|
|
|
48
75
|
- App registration
|
|
49
76
|
- Index management
|
|
50
77
|
- Authentication/authorization setup
|
|
78
|
+
- Optional Ray integration for distributed processing
|
|
79
|
+
- FastAPI integration with lifespan management
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
# Simple usage
|
|
83
|
+
engine = MongoDBEngine(mongo_uri="mongodb://localhost:27017", db_name="mydb")
|
|
84
|
+
await engine.initialize()
|
|
85
|
+
db = engine.get_scoped_db("my_app")
|
|
86
|
+
|
|
87
|
+
# With FastAPI
|
|
88
|
+
app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
|
|
89
|
+
|
|
90
|
+
# With Ray
|
|
91
|
+
engine = MongoDBEngine(..., enable_ray=True)
|
|
51
92
|
"""
|
|
52
93
|
|
|
53
94
|
def __init__(
|
|
54
95
|
self,
|
|
55
96
|
mongo_uri: str,
|
|
56
97
|
db_name: str,
|
|
57
|
-
manifests_dir:
|
|
98
|
+
manifests_dir: Path | None = None,
|
|
58
99
|
authz_provider: Optional["AuthorizationProvider"] = None,
|
|
59
100
|
max_pool_size: int = DEFAULT_MAX_POOL_SIZE,
|
|
60
101
|
min_pool_size: int = DEFAULT_MIN_POOL_SIZE,
|
|
102
|
+
# Optional Ray support
|
|
103
|
+
enable_ray: bool = False,
|
|
104
|
+
ray_namespace: str = "modular_labs",
|
|
61
105
|
) -> None:
|
|
62
106
|
"""
|
|
63
107
|
Initialize the MongoDB Engine.
|
|
@@ -69,6 +113,10 @@ class MongoDBEngine:
|
|
|
69
113
|
authz_provider: Authorization provider instance (optional, can be set later)
|
|
70
114
|
max_pool_size: Maximum MongoDB connection pool size
|
|
71
115
|
min_pool_size: Minimum MongoDB connection pool size
|
|
116
|
+
enable_ray: Enable Ray support for distributed processing.
|
|
117
|
+
Default: False. Only activates if Ray is installed.
|
|
118
|
+
ray_namespace: Ray namespace for actor isolation.
|
|
119
|
+
Default: "modular_labs"
|
|
72
120
|
"""
|
|
73
121
|
self.mongo_uri = mongo_uri
|
|
74
122
|
self.db_name = db_name
|
|
@@ -77,6 +125,11 @@ class MongoDBEngine:
|
|
|
77
125
|
self.max_pool_size = max_pool_size
|
|
78
126
|
self.min_pool_size = min_pool_size
|
|
79
127
|
|
|
128
|
+
# Ray configuration (optional)
|
|
129
|
+
self.enable_ray = enable_ray
|
|
130
|
+
self.ray_namespace = ray_namespace
|
|
131
|
+
self.ray_actor = None # Populated if Ray is enabled and available
|
|
132
|
+
|
|
80
133
|
# Initialize component managers
|
|
81
134
|
self._connection_manager = ConnectionManager(
|
|
82
135
|
mongo_uri=mongo_uri,
|
|
@@ -90,9 +143,23 @@ class MongoDBEngine:
|
|
|
90
143
|
self.manifest_parser = ManifestParser()
|
|
91
144
|
|
|
92
145
|
# Initialize managers (will be set up after connection is established)
|
|
93
|
-
self._app_registration_manager:
|
|
94
|
-
self._index_manager:
|
|
95
|
-
self._service_initializer:
|
|
146
|
+
self._app_registration_manager: AppRegistrationManager | None = None
|
|
147
|
+
self._index_manager: IndexManager | None = None
|
|
148
|
+
self._service_initializer: ServiceInitializer | None = None
|
|
149
|
+
self._encryption_service: EnvelopeEncryptionService | None = None
|
|
150
|
+
self._app_secrets_manager: AppSecretsManager | None = None
|
|
151
|
+
|
|
152
|
+
# Store app read_scopes mapping for validation
|
|
153
|
+
self._app_read_scopes: dict[str, list[str]] = {}
|
|
154
|
+
|
|
155
|
+
# Store app token cache for auto-retrieval
|
|
156
|
+
self._app_token_cache: dict[str, str] = {}
|
|
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
|
|
96
163
|
|
|
97
164
|
async def initialize(self) -> None:
|
|
98
165
|
"""
|
|
@@ -102,6 +169,7 @@ class MongoDBEngine:
|
|
|
102
169
|
1. Connects to MongoDB
|
|
103
170
|
2. Validates the connection
|
|
104
171
|
3. Sets up initial state
|
|
172
|
+
4. Initializes Ray if enabled and available
|
|
105
173
|
|
|
106
174
|
Raises:
|
|
107
175
|
InitializationError: If initialization fails (subclass of RuntimeError
|
|
@@ -111,6 +179,29 @@ class MongoDBEngine:
|
|
|
111
179
|
# Initialize connection
|
|
112
180
|
await self._connection_manager.initialize()
|
|
113
181
|
|
|
182
|
+
# Initialize encryption service
|
|
183
|
+
try:
|
|
184
|
+
from .encryption import MASTER_KEY_ENV_VAR
|
|
185
|
+
|
|
186
|
+
self._encryption_service = EnvelopeEncryptionService()
|
|
187
|
+
except ValueError as e:
|
|
188
|
+
from .encryption import MASTER_KEY_ENV_VAR
|
|
189
|
+
|
|
190
|
+
logger.warning(
|
|
191
|
+
f"Encryption service not initialized: {e}. "
|
|
192
|
+
"App-level authentication will not be available. "
|
|
193
|
+
f"Set {MASTER_KEY_ENV_VAR} environment variable."
|
|
194
|
+
)
|
|
195
|
+
# Continue without encryption (backward compatibility)
|
|
196
|
+
self._encryption_service = None
|
|
197
|
+
|
|
198
|
+
# Initialize app secrets manager (only if encryption service available)
|
|
199
|
+
if self._encryption_service:
|
|
200
|
+
self._app_secrets_manager = AppSecretsManager(
|
|
201
|
+
mongo_db=self._connection_manager.mongo_db,
|
|
202
|
+
encryption_service=self._encryption_service,
|
|
203
|
+
)
|
|
204
|
+
|
|
114
205
|
# Set up component managers
|
|
115
206
|
self._app_registration_manager = AppRegistrationManager(
|
|
116
207
|
mongo_db=self._connection_manager.mongo_db,
|
|
@@ -126,42 +217,99 @@ class MongoDBEngine:
|
|
|
126
217
|
get_scoped_db_fn=self.get_scoped_db,
|
|
127
218
|
)
|
|
128
219
|
|
|
220
|
+
# Initialize Ray if enabled
|
|
221
|
+
if self.enable_ray:
|
|
222
|
+
await self._initialize_ray()
|
|
223
|
+
|
|
224
|
+
async def _initialize_ray(self) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Initialize Ray support (only if enabled and available).
|
|
227
|
+
|
|
228
|
+
This is called automatically during initialize() if enable_ray=True.
|
|
229
|
+
Gracefully degrades if Ray is not installed.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
from .ray_integration import RAY_AVAILABLE, get_ray_actor_handle
|
|
233
|
+
|
|
234
|
+
if not RAY_AVAILABLE:
|
|
235
|
+
logger.warning("Ray enabled but not installed. " "Install with: pip install ray")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Initialize base Ray actor for this engine
|
|
239
|
+
self.ray_actor = await get_ray_actor_handle(
|
|
240
|
+
app_slug="engine",
|
|
241
|
+
namespace=self.ray_namespace,
|
|
242
|
+
mongo_uri=self.mongo_uri,
|
|
243
|
+
db_name=self.db_name,
|
|
244
|
+
create_if_missing=True,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if self.ray_actor:
|
|
248
|
+
logger.info(f"Ray initialized in namespace '{self.ray_namespace}'")
|
|
249
|
+
else:
|
|
250
|
+
logger.warning("Failed to initialize Ray actor")
|
|
251
|
+
|
|
252
|
+
except ImportError:
|
|
253
|
+
logger.warning("Ray integration module not available")
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def has_ray(self) -> bool:
|
|
257
|
+
"""Check if Ray is enabled and initialized."""
|
|
258
|
+
return self.enable_ray and self.ray_actor is not None
|
|
259
|
+
|
|
129
260
|
@property
|
|
130
261
|
def mongo_client(self) -> AsyncIOMotorClient:
|
|
131
262
|
"""
|
|
132
|
-
Get the MongoDB client.
|
|
263
|
+
Get the MongoDB client for observability and health checks.
|
|
264
|
+
|
|
265
|
+
**SECURITY WARNING:** This property exposes the raw MongoDB client.
|
|
266
|
+
It should ONLY be used for:
|
|
267
|
+
- Health checks and observability (`check_mongodb_health`, `get_pool_metrics`)
|
|
268
|
+
- Administrative operations that don't involve data access
|
|
269
|
+
|
|
270
|
+
**DO NOT use this for data access.** Always use `get_scoped_db()` for
|
|
271
|
+
all data operations to ensure proper app scoping and security.
|
|
133
272
|
|
|
134
273
|
Returns:
|
|
135
274
|
AsyncIOMotorClient instance
|
|
136
275
|
|
|
137
276
|
Raises:
|
|
138
277
|
RuntimeError: If engine is not initialized
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
# ✅ CORRECT: Use for health checks
|
|
281
|
+
health = await check_mongodb_health(engine.mongo_client)
|
|
282
|
+
|
|
283
|
+
# ❌ WRONG: Don't use for data access
|
|
284
|
+
db = engine.mongo_client["my_database"] # Bypasses scoping!
|
|
139
285
|
"""
|
|
140
286
|
return self._connection_manager.mongo_client
|
|
141
287
|
|
|
142
288
|
@property
|
|
143
|
-
def
|
|
289
|
+
def _initialized(self) -> bool:
|
|
290
|
+
"""Check if engine is initialized (internal)."""
|
|
291
|
+
return self._connection_manager.initialized
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def initialized(self) -> bool:
|
|
144
295
|
"""
|
|
145
|
-
|
|
296
|
+
Check if engine is initialized.
|
|
146
297
|
|
|
147
298
|
Returns:
|
|
148
|
-
|
|
299
|
+
True if the engine has been initialized, False otherwise.
|
|
149
300
|
|
|
150
|
-
|
|
151
|
-
|
|
301
|
+
Example:
|
|
302
|
+
if engine.initialized:
|
|
303
|
+
db = engine.get_scoped_db("my_app")
|
|
152
304
|
"""
|
|
153
|
-
return self._connection_manager.mongo_db
|
|
154
|
-
|
|
155
|
-
@property
|
|
156
|
-
def _initialized(self) -> bool:
|
|
157
|
-
"""Check if engine is initialized."""
|
|
158
305
|
return self._connection_manager.initialized
|
|
159
306
|
|
|
160
307
|
def get_scoped_db(
|
|
161
308
|
self,
|
|
162
309
|
app_slug: str,
|
|
163
|
-
|
|
164
|
-
|
|
310
|
+
app_token: str | None = None,
|
|
311
|
+
read_scopes: list[str] | None = None,
|
|
312
|
+
write_scope: str | None = None,
|
|
165
313
|
auto_index: bool = True,
|
|
166
314
|
) -> ScopedMongoWrapper:
|
|
167
315
|
"""
|
|
@@ -174,8 +322,12 @@ class MongoDBEngine:
|
|
|
174
322
|
|
|
175
323
|
Args:
|
|
176
324
|
app_slug: App slug (used as default for both read and write scopes)
|
|
177
|
-
|
|
178
|
-
|
|
325
|
+
app_token: App secret token for authentication. Required if app
|
|
326
|
+
secrets manager is initialized. If None and app has stored secret,
|
|
327
|
+
will attempt migration (backward compatibility).
|
|
328
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
329
|
+
read_scopes or defaults to [app_slug]. Allows cross-app data access
|
|
330
|
+
when needed.
|
|
179
331
|
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
180
332
|
All documents inserted through this wrapper will have this as their
|
|
181
333
|
app_id.
|
|
@@ -187,32 +339,215 @@ class MongoDBEngine:
|
|
|
187
339
|
|
|
188
340
|
Raises:
|
|
189
341
|
RuntimeError: If engine is not initialized.
|
|
342
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
190
343
|
|
|
191
344
|
Example:
|
|
192
|
-
>>> db = engine.get_scoped_db("my_app")
|
|
345
|
+
>>> db = engine.get_scoped_db("my_app", app_token="secret-token")
|
|
193
346
|
>>> # All queries are automatically scoped to "my_app"
|
|
194
347
|
>>> doc = await db.my_collection.find_one({"name": "test"})
|
|
195
348
|
"""
|
|
196
349
|
if not self._initialized:
|
|
197
|
-
raise RuntimeError(
|
|
198
|
-
|
|
199
|
-
|
|
350
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
351
|
+
|
|
352
|
+
# Verify app token if secrets manager is available
|
|
353
|
+
# Token verification will happen lazily in ScopedMongoWrapper if called from async context
|
|
354
|
+
if self._app_secrets_manager:
|
|
355
|
+
if app_token is None:
|
|
356
|
+
# Check if app has stored secret (backward compatibility)
|
|
357
|
+
# Use sync wrapper that handles async context
|
|
358
|
+
has_secret = self._app_secrets_manager.app_secret_exists_sync(app_slug)
|
|
359
|
+
if has_secret:
|
|
360
|
+
# Log detailed info
|
|
361
|
+
logger.warning(f"App token required for '{app_slug}'")
|
|
362
|
+
# Generic error message
|
|
363
|
+
raise ValueError("App token required. Provide app_token parameter.")
|
|
364
|
+
# No stored secret - allow (backward compatibility for apps without secrets)
|
|
365
|
+
logger.debug(
|
|
366
|
+
f"App '{app_slug}' has no stored secret, "
|
|
367
|
+
f"allowing access (backward compatibility)"
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
# Try to verify synchronously if possible, otherwise pass to wrapper
|
|
371
|
+
# for lazy verification
|
|
372
|
+
import asyncio
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
# Check if we're in an async context
|
|
376
|
+
asyncio.get_running_loop()
|
|
377
|
+
# We're in async context - can't verify synchronously without blocking
|
|
378
|
+
# Pass token to wrapper for lazy verification on first database operation
|
|
379
|
+
logger.debug(
|
|
380
|
+
f"Token verification deferred to first database operation for '{app_slug}' "
|
|
381
|
+
f"(async context detected)"
|
|
382
|
+
)
|
|
383
|
+
except RuntimeError:
|
|
384
|
+
# No event loop - safe to use sync verification
|
|
385
|
+
is_valid = self._app_secrets_manager.verify_app_secret_sync(app_slug, app_token)
|
|
386
|
+
if not is_valid:
|
|
387
|
+
# Log detailed info with app_slug
|
|
388
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
389
|
+
# Generic error message (from None: unrelated to RuntimeError)
|
|
390
|
+
raise ValueError("Invalid app token") from None
|
|
391
|
+
|
|
392
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
393
|
+
if read_scopes is not None:
|
|
394
|
+
if not isinstance(read_scopes, list):
|
|
395
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
396
|
+
if len(read_scopes) == 0:
|
|
397
|
+
raise ValueError("read_scopes cannot be empty")
|
|
398
|
+
|
|
399
|
+
# Use manifest read_scopes if not provided
|
|
400
|
+
if read_scopes is None:
|
|
401
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
402
|
+
|
|
403
|
+
if write_scope is None:
|
|
404
|
+
write_scope = app_slug
|
|
405
|
+
|
|
406
|
+
# Validate requested read_scopes against manifest authorization
|
|
407
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
408
|
+
for scope in read_scopes:
|
|
409
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
410
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
411
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
412
|
+
if scope not in authorized_scopes:
|
|
413
|
+
logger.warning(
|
|
414
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
415
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
416
|
+
)
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"App not authorized to read from requested scope. "
|
|
419
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
420
|
+
)
|
|
421
|
+
if not read_scopes:
|
|
422
|
+
raise ValueError("read_scopes cannot be empty")
|
|
423
|
+
for scope in read_scopes:
|
|
424
|
+
if not isinstance(scope, str) or not scope:
|
|
425
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
426
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
427
|
+
|
|
428
|
+
# Validate write_scope
|
|
429
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
430
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
431
|
+
|
|
432
|
+
return ScopedMongoWrapper(
|
|
433
|
+
real_db=self._connection_manager.mongo_db,
|
|
434
|
+
read_scopes=read_scopes,
|
|
435
|
+
write_scope=write_scope,
|
|
436
|
+
auto_index=auto_index,
|
|
437
|
+
app_slug=app_slug,
|
|
438
|
+
app_token=app_token,
|
|
439
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
async def get_scoped_db_async(
|
|
443
|
+
self,
|
|
444
|
+
app_slug: str,
|
|
445
|
+
app_token: str | None = None,
|
|
446
|
+
read_scopes: list[str] | None = None,
|
|
447
|
+
write_scope: str | None = None,
|
|
448
|
+
auto_index: bool = True,
|
|
449
|
+
) -> ScopedMongoWrapper:
|
|
450
|
+
"""
|
|
451
|
+
Asynchronous version of get_scoped_db that properly verifies tokens.
|
|
452
|
+
|
|
453
|
+
This method is preferred in async contexts to ensure token verification
|
|
454
|
+
happens correctly.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
app_slug: App slug (used as default for both read and write scopes)
|
|
458
|
+
app_token: App secret token for authentication. Required if app
|
|
459
|
+
secrets manager is initialized.
|
|
460
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
461
|
+
read_scopes or defaults to [app_slug].
|
|
462
|
+
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
463
|
+
auto_index: Whether to enable automatic index creation.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
ScopedMongoWrapper instance configured with the specified scopes.
|
|
200
467
|
|
|
468
|
+
Raises:
|
|
469
|
+
RuntimeError: If engine is not initialized.
|
|
470
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
471
|
+
"""
|
|
472
|
+
if not self._initialized:
|
|
473
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
474
|
+
|
|
475
|
+
# Verify app token if secrets manager is available
|
|
476
|
+
if self._app_secrets_manager:
|
|
477
|
+
if app_token is None:
|
|
478
|
+
# Check if app has stored secret
|
|
479
|
+
has_secret = await self._app_secrets_manager.app_secret_exists(app_slug)
|
|
480
|
+
if has_secret:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
f"App token required for '{app_slug}'. " "Provide app_token parameter."
|
|
483
|
+
)
|
|
484
|
+
# No stored secret - allow (backward compatibility)
|
|
485
|
+
logger.debug(
|
|
486
|
+
f"App '{app_slug}' has no stored secret, "
|
|
487
|
+
f"allowing access (backward compatibility)"
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
# Verify token asynchronously
|
|
491
|
+
is_valid = await self._app_secrets_manager.verify_app_secret(app_slug, app_token)
|
|
492
|
+
if not is_valid:
|
|
493
|
+
# Log detailed info with app_slug
|
|
494
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
495
|
+
# Generic error message
|
|
496
|
+
raise ValueError("Invalid app token")
|
|
497
|
+
|
|
498
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
499
|
+
if read_scopes is not None:
|
|
500
|
+
if not isinstance(read_scopes, list):
|
|
501
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
502
|
+
if len(read_scopes) == 0:
|
|
503
|
+
raise ValueError("read_scopes cannot be empty")
|
|
504
|
+
|
|
505
|
+
# Use manifest read_scopes if not provided
|
|
201
506
|
if read_scopes is None:
|
|
202
|
-
read_scopes = [app_slug]
|
|
507
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
508
|
+
|
|
203
509
|
if write_scope is None:
|
|
204
510
|
write_scope = app_slug
|
|
205
511
|
|
|
512
|
+
# Validate requested read_scopes against manifest authorization
|
|
513
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
514
|
+
for scope in read_scopes:
|
|
515
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
516
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
517
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
518
|
+
if scope not in authorized_scopes:
|
|
519
|
+
logger.warning(
|
|
520
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
521
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
522
|
+
)
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"App not authorized to read from requested scope. "
|
|
525
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
526
|
+
)
|
|
527
|
+
if not read_scopes:
|
|
528
|
+
raise ValueError("read_scopes cannot be empty")
|
|
529
|
+
for scope in read_scopes:
|
|
530
|
+
if not isinstance(scope, str) or not scope:
|
|
531
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
532
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
533
|
+
|
|
534
|
+
# Validate write_scope
|
|
535
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
536
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
537
|
+
|
|
206
538
|
return ScopedMongoWrapper(
|
|
207
539
|
real_db=self._connection_manager.mongo_db,
|
|
208
540
|
read_scopes=read_scopes,
|
|
209
541
|
write_scope=write_scope,
|
|
210
542
|
auto_index=auto_index,
|
|
543
|
+
app_slug=app_slug,
|
|
544
|
+
app_token=app_token,
|
|
545
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
211
546
|
)
|
|
212
547
|
|
|
213
548
|
async def validate_manifest(
|
|
214
549
|
self, manifest: "ManifestDict"
|
|
215
|
-
) ->
|
|
550
|
+
) -> tuple[bool, str | None, list[str] | None]:
|
|
216
551
|
"""
|
|
217
552
|
Validate a manifest against the schema.
|
|
218
553
|
|
|
@@ -227,9 +562,7 @@ class MongoDBEngine:
|
|
|
227
562
|
- error_paths: List of JSON paths with validation errors, None if valid
|
|
228
563
|
"""
|
|
229
564
|
if not self._app_registration_manager:
|
|
230
|
-
raise RuntimeError(
|
|
231
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
232
|
-
)
|
|
565
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
233
566
|
return await self._app_registration_manager.validate_manifest(manifest)
|
|
234
567
|
|
|
235
568
|
async def load_manifest(self, path: Path) -> "ManifestDict":
|
|
@@ -247,14 +580,10 @@ class MongoDBEngine:
|
|
|
247
580
|
ValueError: If validation fails
|
|
248
581
|
"""
|
|
249
582
|
if not self._app_registration_manager:
|
|
250
|
-
raise RuntimeError(
|
|
251
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
252
|
-
)
|
|
583
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
253
584
|
return await self._app_registration_manager.load_manifest(path)
|
|
254
585
|
|
|
255
|
-
async def register_app(
|
|
256
|
-
self, manifest: "ManifestDict", create_indexes: bool = True
|
|
257
|
-
) -> bool:
|
|
586
|
+
async def register_app(self, manifest: "ManifestDict", create_indexes: bool = True) -> bool:
|
|
258
587
|
"""
|
|
259
588
|
Register an app from its manifest.
|
|
260
589
|
|
|
@@ -275,46 +604,39 @@ class MongoDBEngine:
|
|
|
275
604
|
RuntimeError: If engine is not initialized.
|
|
276
605
|
"""
|
|
277
606
|
if not self._app_registration_manager:
|
|
278
|
-
raise RuntimeError(
|
|
279
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
280
|
-
)
|
|
607
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
281
608
|
|
|
282
609
|
# Create callbacks for service initialization
|
|
283
610
|
async def create_indexes_callback(slug: str, manifest: "ManifestDict") -> None:
|
|
284
611
|
if self._index_manager and create_indexes:
|
|
285
612
|
await self._index_manager.create_app_indexes(slug, manifest)
|
|
286
613
|
|
|
287
|
-
async def seed_data_callback(slug: str, initial_data:
|
|
614
|
+
async def seed_data_callback(slug: str, initial_data: dict[str, Any]) -> None:
|
|
288
615
|
if self._service_initializer:
|
|
289
616
|
await self._service_initializer.seed_initial_data(slug, initial_data)
|
|
290
617
|
|
|
291
|
-
async def initialize_memory_callback(
|
|
292
|
-
slug: str, memory_config: Dict[str, Any]
|
|
293
|
-
) -> None:
|
|
618
|
+
async def initialize_memory_callback(slug: str, memory_config: dict[str, Any]) -> None:
|
|
294
619
|
if self._service_initializer:
|
|
295
|
-
await self._service_initializer.initialize_memory_service(
|
|
296
|
-
slug, memory_config
|
|
297
|
-
)
|
|
620
|
+
await self._service_initializer.initialize_memory_service(slug, memory_config)
|
|
298
621
|
|
|
299
622
|
async def register_websockets_callback(
|
|
300
|
-
slug: str, websockets_config:
|
|
623
|
+
slug: str, websockets_config: dict[str, Any]
|
|
301
624
|
) -> None:
|
|
302
625
|
if self._service_initializer:
|
|
303
|
-
await self._service_initializer.register_websockets(
|
|
304
|
-
slug, websockets_config
|
|
305
|
-
)
|
|
626
|
+
await self._service_initializer.register_websockets(slug, websockets_config)
|
|
306
627
|
|
|
307
628
|
async def setup_observability_callback(
|
|
308
629
|
slug: str,
|
|
309
630
|
manifest: "ManifestDict",
|
|
310
|
-
observability_config:
|
|
631
|
+
observability_config: dict[str, Any],
|
|
311
632
|
) -> None:
|
|
312
633
|
if self._service_initializer:
|
|
313
634
|
await self._service_initializer.setup_observability(
|
|
314
635
|
slug, manifest, observability_config
|
|
315
636
|
)
|
|
316
637
|
|
|
317
|
-
|
|
638
|
+
# Register app first (this validates and stores the manifest)
|
|
639
|
+
result = await self._app_registration_manager.register_app(
|
|
318
640
|
manifest=manifest,
|
|
319
641
|
create_indexes_callback=create_indexes_callback if create_indexes else None,
|
|
320
642
|
seed_data_callback=seed_data_callback,
|
|
@@ -323,7 +645,34 @@ class MongoDBEngine:
|
|
|
323
645
|
setup_observability_callback=setup_observability_callback,
|
|
324
646
|
)
|
|
325
647
|
|
|
326
|
-
|
|
648
|
+
# Extract and store data_access configuration AFTER registration
|
|
649
|
+
slug = manifest.get("slug")
|
|
650
|
+
if slug:
|
|
651
|
+
data_access = manifest.get("data_access", {})
|
|
652
|
+
read_scopes = data_access.get("read_scopes")
|
|
653
|
+
if read_scopes:
|
|
654
|
+
self._app_read_scopes[slug] = read_scopes
|
|
655
|
+
else:
|
|
656
|
+
# Default to app_slug if not specified
|
|
657
|
+
self._app_read_scopes[slug] = [slug]
|
|
658
|
+
|
|
659
|
+
# Generate and store app secret if secrets manager is available
|
|
660
|
+
if self._app_secrets_manager:
|
|
661
|
+
# Check if secret already exists (don't overwrite)
|
|
662
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
663
|
+
if not secret_exists:
|
|
664
|
+
app_secret = secrets.token_urlsafe(32)
|
|
665
|
+
await self._app_secrets_manager.store_app_secret(slug, app_secret)
|
|
666
|
+
logger.info(
|
|
667
|
+
f"Generated and stored encrypted secret for app '{slug}'. "
|
|
668
|
+
"Store this secret securely and provide it as app_token in get_scoped_db()."
|
|
669
|
+
)
|
|
670
|
+
# Note: In production, the secret should be retrieved via rotation API
|
|
671
|
+
# For now, we log it (in production, this should be handled differently)
|
|
672
|
+
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
def get_websocket_config(self, slug: str) -> dict[str, Any] | None:
|
|
327
676
|
"""
|
|
328
677
|
Get WebSocket configuration for an app.
|
|
329
678
|
|
|
@@ -428,15 +777,9 @@ class MongoDBEngine:
|
|
|
428
777
|
# Include the router in the app
|
|
429
778
|
app.include_router(ws_router)
|
|
430
779
|
|
|
431
|
-
print(
|
|
432
|
-
|
|
433
|
-
)
|
|
434
|
-
print(
|
|
435
|
-
f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}"
|
|
436
|
-
)
|
|
437
|
-
print(
|
|
438
|
-
f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}"
|
|
439
|
-
)
|
|
780
|
+
print(f"✅ Registered WebSocket route '{path}' for app '{slug}' using APIRouter")
|
|
781
|
+
print(f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}")
|
|
782
|
+
print(f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}")
|
|
440
783
|
print(f" Route path: {path}, Full route count: {len(app.routes)}")
|
|
441
784
|
contextual_logger.info(
|
|
442
785
|
f"✅ Registered WebSocket route '{path}' for app '{slug}' "
|
|
@@ -459,9 +802,7 @@ class MongoDBEngine:
|
|
|
459
802
|
"error": str(e),
|
|
460
803
|
},
|
|
461
804
|
)
|
|
462
|
-
print(
|
|
463
|
-
f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}"
|
|
464
|
-
)
|
|
805
|
+
print(f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}")
|
|
465
806
|
import traceback
|
|
466
807
|
|
|
467
808
|
traceback.print_exc()
|
|
@@ -483,9 +824,7 @@ class MongoDBEngine:
|
|
|
483
824
|
RuntimeError: If engine is not initialized.
|
|
484
825
|
"""
|
|
485
826
|
if not self._app_registration_manager:
|
|
486
|
-
raise RuntimeError(
|
|
487
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
488
|
-
)
|
|
827
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
489
828
|
|
|
490
829
|
return await self._app_registration_manager.reload_apps(
|
|
491
830
|
register_app_callback=self.register_app
|
|
@@ -502,9 +841,7 @@ class MongoDBEngine:
|
|
|
502
841
|
App manifest dict or None if not found
|
|
503
842
|
"""
|
|
504
843
|
if not self._app_registration_manager:
|
|
505
|
-
raise RuntimeError(
|
|
506
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
507
|
-
)
|
|
844
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
508
845
|
return self._app_registration_manager.get_app(slug)
|
|
509
846
|
|
|
510
847
|
async def get_manifest(self, slug: str) -> Optional["ManifestDict"]:
|
|
@@ -518,21 +855,10 @@ class MongoDBEngine:
|
|
|
518
855
|
App manifest dict or None if not found
|
|
519
856
|
"""
|
|
520
857
|
if not self._app_registration_manager:
|
|
521
|
-
raise RuntimeError(
|
|
522
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
523
|
-
)
|
|
858
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
524
859
|
return await self._app_registration_manager.get_manifest(slug)
|
|
525
860
|
|
|
526
|
-
def
|
|
527
|
-
"""
|
|
528
|
-
Get the MongoDB database instance.
|
|
529
|
-
|
|
530
|
-
Returns:
|
|
531
|
-
AsyncIOMotorDatabase instance
|
|
532
|
-
"""
|
|
533
|
-
return self.mongo_db
|
|
534
|
-
|
|
535
|
-
def get_memory_service(self, slug: str) -> Optional[Any]:
|
|
861
|
+
def get_memory_service(self, slug: str) -> Any | None:
|
|
536
862
|
"""
|
|
537
863
|
Get Mem0 memory service for an app.
|
|
538
864
|
|
|
@@ -556,8 +882,32 @@ class MongoDBEngine:
|
|
|
556
882
|
return self._service_initializer.get_memory_service(slug)
|
|
557
883
|
return None
|
|
558
884
|
|
|
885
|
+
def get_embedding_service(self, slug: str) -> Any | None:
|
|
886
|
+
"""
|
|
887
|
+
Get EmbeddingService for an app.
|
|
888
|
+
|
|
889
|
+
Auto-detects OpenAI or AzureOpenAI from environment variables.
|
|
890
|
+
Uses embedding_config from manifest.json if available.
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
slug: App slug
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
EmbeddingService instance if embedding is enabled for this app, None otherwise
|
|
897
|
+
|
|
898
|
+
Example:
|
|
899
|
+
```python
|
|
900
|
+
embedding_service = engine.get_embedding_service("my_app")
|
|
901
|
+
if embedding_service:
|
|
902
|
+
vectors = await embedding_service.embed_chunks(["Hello world"])
|
|
903
|
+
```
|
|
904
|
+
"""
|
|
905
|
+
from ..embeddings.dependencies import get_embedding_service_for_app
|
|
906
|
+
|
|
907
|
+
return get_embedding_service_for_app(slug, self)
|
|
908
|
+
|
|
559
909
|
@property
|
|
560
|
-
def _apps(self) ->
|
|
910
|
+
def _apps(self) -> dict[str, Any]:
|
|
561
911
|
"""
|
|
562
912
|
Get the apps dictionary (for backward compatibility with tests).
|
|
563
913
|
|
|
@@ -568,12 +918,10 @@ class MongoDBEngine:
|
|
|
568
918
|
RuntimeError: If engine is not initialized
|
|
569
919
|
"""
|
|
570
920
|
if not self._app_registration_manager:
|
|
571
|
-
raise RuntimeError(
|
|
572
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
573
|
-
)
|
|
921
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
574
922
|
return self._app_registration_manager._apps
|
|
575
923
|
|
|
576
|
-
def list_apps(self) ->
|
|
924
|
+
def list_apps(self) -> list[str]:
|
|
577
925
|
"""
|
|
578
926
|
List all registered app slugs.
|
|
579
927
|
|
|
@@ -581,9 +929,7 @@ class MongoDBEngine:
|
|
|
581
929
|
List of app slugs
|
|
582
930
|
"""
|
|
583
931
|
if not self._app_registration_manager:
|
|
584
|
-
raise RuntimeError(
|
|
585
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
586
|
-
)
|
|
932
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
587
933
|
return self._app_registration_manager.list_apps()
|
|
588
934
|
|
|
589
935
|
async def shutdown(self) -> None:
|
|
@@ -619,9 +965,9 @@ class MongoDBEngine:
|
|
|
619
965
|
|
|
620
966
|
def __exit__(
|
|
621
967
|
self,
|
|
622
|
-
exc_type:
|
|
623
|
-
exc_val:
|
|
624
|
-
exc_tb:
|
|
968
|
+
exc_type: type[BaseException] | None,
|
|
969
|
+
exc_val: BaseException | None,
|
|
970
|
+
exc_tb: Any | None,
|
|
625
971
|
) -> None:
|
|
626
972
|
"""
|
|
627
973
|
Context manager exit (synchronous).
|
|
@@ -652,9 +998,9 @@ class MongoDBEngine:
|
|
|
652
998
|
|
|
653
999
|
async def __aexit__(
|
|
654
1000
|
self,
|
|
655
|
-
exc_type:
|
|
656
|
-
exc_val:
|
|
657
|
-
exc_tb:
|
|
1001
|
+
exc_type: type[BaseException] | None,
|
|
1002
|
+
exc_val: BaseException | None,
|
|
1003
|
+
exc_tb: Any | None,
|
|
658
1004
|
) -> None:
|
|
659
1005
|
"""
|
|
660
1006
|
Async context manager exit.
|
|
@@ -668,7 +1014,7 @@ class MongoDBEngine:
|
|
|
668
1014
|
"""
|
|
669
1015
|
await self.shutdown()
|
|
670
1016
|
|
|
671
|
-
async def get_health_status(self) ->
|
|
1017
|
+
async def get_health_status(self) -> dict[str, Any]:
|
|
672
1018
|
"""
|
|
673
1019
|
Get health status of the MongoDB Engine.
|
|
674
1020
|
|
|
@@ -693,20 +1039,12 @@ class MongoDBEngine:
|
|
|
693
1039
|
# This follows MongoDB best practice: monitor the actual client
|
|
694
1040
|
# being used
|
|
695
1041
|
async def get_metrics():
|
|
696
|
-
metrics = await get_pool_metrics(
|
|
697
|
-
self._connection_manager.mongo_client
|
|
698
|
-
)
|
|
1042
|
+
metrics = await get_pool_metrics(self._connection_manager.mongo_client)
|
|
699
1043
|
# Add MongoDBEngine's pool configuration if not already in metrics
|
|
700
1044
|
if metrics.get("status") == "connected":
|
|
701
|
-
if (
|
|
702
|
-
"max_pool_size" not in metrics
|
|
703
|
-
or metrics.get("max_pool_size") is None
|
|
704
|
-
):
|
|
1045
|
+
if "max_pool_size" not in metrics or metrics.get("max_pool_size") is None:
|
|
705
1046
|
metrics["max_pool_size"] = self.max_pool_size
|
|
706
|
-
if (
|
|
707
|
-
"min_pool_size" not in metrics
|
|
708
|
-
or metrics.get("min_pool_size") is None
|
|
709
|
-
):
|
|
1047
|
+
if "min_pool_size" not in metrics or metrics.get("min_pool_size") is None:
|
|
710
1048
|
metrics["min_pool_size"] = self.min_pool_size
|
|
711
1049
|
return metrics
|
|
712
1050
|
|
|
@@ -719,8 +1057,10 @@ class MongoDBEngine:
|
|
|
719
1057
|
usage = details.get("pool_usage_percent", 0)
|
|
720
1058
|
if usage <= 90 and details.get("status") == "connected":
|
|
721
1059
|
# Not critical, downgrade to degraded
|
|
722
|
-
from ..observability.health import (
|
|
723
|
-
|
|
1060
|
+
from ..observability.health import (
|
|
1061
|
+
HealthCheckResult,
|
|
1062
|
+
HealthStatus,
|
|
1063
|
+
)
|
|
724
1064
|
|
|
725
1065
|
return HealthCheckResult(
|
|
726
1066
|
name=result.name,
|
|
@@ -736,7 +1076,7 @@ class MongoDBEngine:
|
|
|
736
1076
|
|
|
737
1077
|
return await health_checker.check_all()
|
|
738
1078
|
|
|
739
|
-
def get_metrics(self) ->
|
|
1079
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
740
1080
|
"""
|
|
741
1081
|
Get metrics for the MongoDB Engine.
|
|
742
1082
|
|
|
@@ -747,3 +1087,2410 @@ class MongoDBEngine:
|
|
|
747
1087
|
|
|
748
1088
|
collector = get_metrics_collector()
|
|
749
1089
|
return collector.get_summary()
|
|
1090
|
+
|
|
1091
|
+
# =========================================================================
|
|
1092
|
+
# FastAPI Integration Methods
|
|
1093
|
+
# =========================================================================
|
|
1094
|
+
|
|
1095
|
+
def create_app(
|
|
1096
|
+
self,
|
|
1097
|
+
slug: str,
|
|
1098
|
+
manifest: Path,
|
|
1099
|
+
title: str | None = None,
|
|
1100
|
+
on_startup: Callable[["FastAPI", "MongoDBEngine", dict[str, Any]], Awaitable[None]]
|
|
1101
|
+
| None = None,
|
|
1102
|
+
on_shutdown: Callable[["FastAPI", "MongoDBEngine", dict[str, Any]], Awaitable[None]]
|
|
1103
|
+
| None = None,
|
|
1104
|
+
is_sub_app: bool = False,
|
|
1105
|
+
**fastapi_kwargs: Any,
|
|
1106
|
+
) -> "FastAPI":
|
|
1107
|
+
"""
|
|
1108
|
+
Create a FastAPI application with proper lifespan management.
|
|
1109
|
+
|
|
1110
|
+
This method creates a FastAPI app that:
|
|
1111
|
+
1. Initializes the engine on startup (unless is_sub_app=True)
|
|
1112
|
+
2. Loads and registers the manifest
|
|
1113
|
+
3. Auto-detects multi-site mode from manifest
|
|
1114
|
+
4. Auto-configures auth based on manifest auth.mode:
|
|
1115
|
+
- "app" (default): Per-app token authentication
|
|
1116
|
+
- "shared": Shared user pool with SSO, auto-adds SharedAuthMiddleware
|
|
1117
|
+
5. Auto-retrieves app tokens (for "app" mode)
|
|
1118
|
+
6. Calls on_startup callback (if provided)
|
|
1119
|
+
7. Shuts down the engine on shutdown (calls on_shutdown first if provided)
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
slug: Application slug (must match manifest slug)
|
|
1123
|
+
manifest: Path to manifest.json file
|
|
1124
|
+
title: FastAPI app title. Defaults to app name from manifest
|
|
1125
|
+
on_startup: Optional async callback called after engine initialization.
|
|
1126
|
+
Signature: async def callback(app, engine, manifest) -> None
|
|
1127
|
+
on_shutdown: Optional async callback called before engine shutdown.
|
|
1128
|
+
Signature: async def callback(app, engine, manifest) -> None
|
|
1129
|
+
is_sub_app: If True, skip engine initialization and lifespan management.
|
|
1130
|
+
Used when mounting as a child app in create_multi_app().
|
|
1131
|
+
Defaults to False for backward compatibility.
|
|
1132
|
+
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1133
|
+
|
|
1134
|
+
Returns:
|
|
1135
|
+
Configured FastAPI application
|
|
1136
|
+
|
|
1137
|
+
Example:
|
|
1138
|
+
async def my_startup(app, engine, manifest):
|
|
1139
|
+
db = engine.get_scoped_db("my_app")
|
|
1140
|
+
await db.config.insert_one({"initialized": True})
|
|
1141
|
+
|
|
1142
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
1143
|
+
app = engine.create_app(
|
|
1144
|
+
slug="my_app",
|
|
1145
|
+
manifest=Path("manifest.json"),
|
|
1146
|
+
on_startup=my_startup,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
@app.get("/")
|
|
1150
|
+
async def index():
|
|
1151
|
+
db = engine.get_scoped_db("my_app")
|
|
1152
|
+
return {"status": "ok"}
|
|
1153
|
+
|
|
1154
|
+
Auth Modes (configured in manifest.json):
|
|
1155
|
+
# Per-app auth (default)
|
|
1156
|
+
{"auth": {"mode": "app"}}
|
|
1157
|
+
|
|
1158
|
+
# Shared user pool with SSO
|
|
1159
|
+
{"auth": {"mode": "shared", "roles": ["viewer", "editor", "admin"],
|
|
1160
|
+
"require_role": "viewer", "public_routes": ["/health"]}}
|
|
1161
|
+
"""
|
|
1162
|
+
import json
|
|
1163
|
+
|
|
1164
|
+
from fastapi import FastAPI
|
|
1165
|
+
|
|
1166
|
+
engine = self
|
|
1167
|
+
manifest_path = Path(manifest)
|
|
1168
|
+
|
|
1169
|
+
# Pre-load manifest synchronously to detect auth mode BEFORE creating app
|
|
1170
|
+
# This allows us to add middleware at app creation time (before startup)
|
|
1171
|
+
with open(manifest_path) as f:
|
|
1172
|
+
pre_manifest = json.load(f)
|
|
1173
|
+
|
|
1174
|
+
# Extract auth configuration
|
|
1175
|
+
auth_config = pre_manifest.get("auth", {})
|
|
1176
|
+
auth_mode = auth_config.get("mode", "app")
|
|
1177
|
+
|
|
1178
|
+
# Determine title from pre-loaded manifest or slug
|
|
1179
|
+
app_title = title or pre_manifest.get("name", slug)
|
|
1180
|
+
|
|
1181
|
+
# State that will be populated during initialization
|
|
1182
|
+
app_manifest: dict[str, Any] = {}
|
|
1183
|
+
is_multi_site = False
|
|
1184
|
+
|
|
1185
|
+
@asynccontextmanager
|
|
1186
|
+
async def lifespan(app: FastAPI):
|
|
1187
|
+
"""Lifespan context manager for initialization and cleanup."""
|
|
1188
|
+
nonlocal app_manifest, is_multi_site
|
|
1189
|
+
|
|
1190
|
+
# Initialize engine (skip if sub-app - parent manages lifecycle)
|
|
1191
|
+
if not is_sub_app:
|
|
1192
|
+
await engine.initialize()
|
|
1193
|
+
|
|
1194
|
+
# Load and register manifest
|
|
1195
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
1196
|
+
await engine.register_app(app_manifest)
|
|
1197
|
+
|
|
1198
|
+
# Auto-detect multi-site mode from manifest
|
|
1199
|
+
data_access = app_manifest.get("data_access", {})
|
|
1200
|
+
read_scopes = data_access.get("read_scopes", [slug])
|
|
1201
|
+
cross_app_policy = data_access.get("cross_app_policy", "none")
|
|
1202
|
+
|
|
1203
|
+
# Multi-site if: cross_app_policy is "explicit" OR read_scopes has multiple apps
|
|
1204
|
+
is_multi_site = cross_app_policy == "explicit" or (
|
|
1205
|
+
len(read_scopes) > 1 and read_scopes != [slug]
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
if is_multi_site:
|
|
1209
|
+
logger.info(
|
|
1210
|
+
f"Multi-site mode detected for '{slug}': "
|
|
1211
|
+
f"read_scopes={read_scopes}, cross_app_policy={cross_app_policy}"
|
|
1212
|
+
)
|
|
1213
|
+
else:
|
|
1214
|
+
logger.info(f"Single-app mode for '{slug}'")
|
|
1215
|
+
|
|
1216
|
+
# Handle auth based on mode
|
|
1217
|
+
if auth_mode == "shared":
|
|
1218
|
+
logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
|
|
1219
|
+
# Initialize shared user pool and set on app.state
|
|
1220
|
+
# Middleware was already added at app creation time (lazy version)
|
|
1221
|
+
# For sub-apps, check if parent already initialized user pool
|
|
1222
|
+
if is_sub_app:
|
|
1223
|
+
# Check if parent app has user_pool (set by parent's initialization)
|
|
1224
|
+
# If not, initialize it (shouldn't happen, but handle gracefully)
|
|
1225
|
+
if not hasattr(app.state, "user_pool") or app.state.user_pool is None:
|
|
1226
|
+
logger.warning(
|
|
1227
|
+
f"Sub-app '{slug}' uses shared auth but user_pool not found. "
|
|
1228
|
+
"Initializing now (parent should have initialized it)."
|
|
1229
|
+
)
|
|
1230
|
+
await engine._initialize_shared_user_pool(app, app_manifest)
|
|
1231
|
+
else:
|
|
1232
|
+
logger.debug(f"Sub-app '{slug}' using shared user_pool from parent app")
|
|
1233
|
+
else:
|
|
1234
|
+
await engine._initialize_shared_user_pool(app, app_manifest)
|
|
1235
|
+
else:
|
|
1236
|
+
logger.info(f"Per-app auth mode for '{slug}'")
|
|
1237
|
+
# Auto-retrieve app token for "app" mode
|
|
1238
|
+
await engine.auto_retrieve_app_token(slug)
|
|
1239
|
+
|
|
1240
|
+
# Auto-initialize authorization provider from manifest config
|
|
1241
|
+
try:
|
|
1242
|
+
logger.info(
|
|
1243
|
+
f"🔍 Checking auth config for '{slug}': "
|
|
1244
|
+
f"auth_config keys={list(auth_config.keys())}"
|
|
1245
|
+
)
|
|
1246
|
+
auth_policy = auth_config.get("policy", {})
|
|
1247
|
+
logger.info(f"🔍 Auth policy for '{slug}': {auth_policy}")
|
|
1248
|
+
authz_provider_type = auth_policy.get("provider")
|
|
1249
|
+
logger.info(f"🔍 Authz provider type for '{slug}': {authz_provider_type}")
|
|
1250
|
+
except (KeyError, AttributeError, TypeError) as e:
|
|
1251
|
+
logger.exception(f"❌ Error reading auth config for '{slug}': {e}")
|
|
1252
|
+
authz_provider_type = None
|
|
1253
|
+
|
|
1254
|
+
if authz_provider_type == "oso":
|
|
1255
|
+
# Initialize OSO Cloud provider
|
|
1256
|
+
try:
|
|
1257
|
+
from ..auth.oso_factory import initialize_oso_from_manifest
|
|
1258
|
+
|
|
1259
|
+
authz_provider = await initialize_oso_from_manifest(engine, slug, app_manifest)
|
|
1260
|
+
if authz_provider:
|
|
1261
|
+
app.state.authz_provider = authz_provider
|
|
1262
|
+
logger.info(f"✅ OSO Cloud provider auto-initialized for '{slug}'")
|
|
1263
|
+
else:
|
|
1264
|
+
logger.warning(
|
|
1265
|
+
f"⚠️ OSO provider not initialized for '{slug}' - "
|
|
1266
|
+
"check OSO_AUTH and OSO_URL environment variables"
|
|
1267
|
+
)
|
|
1268
|
+
except ImportError as e:
|
|
1269
|
+
logger.warning(
|
|
1270
|
+
f"⚠️ OSO Cloud SDK not available for '{slug}': {e}. "
|
|
1271
|
+
"Install with: pip install oso-cloud"
|
|
1272
|
+
)
|
|
1273
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1274
|
+
logger.exception(f"❌ Failed to initialize OSO provider for '{slug}': {e}")
|
|
1275
|
+
|
|
1276
|
+
elif authz_provider_type == "casbin":
|
|
1277
|
+
# Initialize Casbin provider
|
|
1278
|
+
logger.info(f"🔧 Initializing Casbin provider for '{slug}'...")
|
|
1279
|
+
try:
|
|
1280
|
+
from ..auth.casbin_factory import initialize_casbin_from_manifest
|
|
1281
|
+
|
|
1282
|
+
logger.debug(f"Calling initialize_casbin_from_manifest for '{slug}'")
|
|
1283
|
+
authz_provider = await initialize_casbin_from_manifest(
|
|
1284
|
+
engine, slug, app_manifest
|
|
1285
|
+
)
|
|
1286
|
+
logger.debug(
|
|
1287
|
+
f"initialize_casbin_from_manifest returned: {authz_provider is not None}"
|
|
1288
|
+
)
|
|
1289
|
+
if authz_provider:
|
|
1290
|
+
app.state.authz_provider = authz_provider
|
|
1291
|
+
logger.info(
|
|
1292
|
+
f"✅ Casbin provider auto-initialized for '{slug}' "
|
|
1293
|
+
f"and set on app.state"
|
|
1294
|
+
)
|
|
1295
|
+
logger.info(
|
|
1296
|
+
f"✅ Provider type: {type(authz_provider).__name__}, "
|
|
1297
|
+
f"initialized: {getattr(authz_provider, '_initialized', 'unknown')}"
|
|
1298
|
+
)
|
|
1299
|
+
# Verify it's actually set
|
|
1300
|
+
if hasattr(app.state, "authz_provider") and app.state.authz_provider:
|
|
1301
|
+
logger.info("✅ Verified: app.state.authz_provider is set and not None")
|
|
1302
|
+
else:
|
|
1303
|
+
logger.error(
|
|
1304
|
+
"❌ CRITICAL: app.state.authz_provider was set but is now "
|
|
1305
|
+
"None or missing!"
|
|
1306
|
+
)
|
|
1307
|
+
else:
|
|
1308
|
+
logger.error(
|
|
1309
|
+
f"❌ Casbin provider initialization returned None for '{slug}' - "
|
|
1310
|
+
f"check logs above for errors"
|
|
1311
|
+
)
|
|
1312
|
+
logger.error(f"❌ This means authorization will NOT work for '{slug}'")
|
|
1313
|
+
except ImportError as e:
|
|
1314
|
+
# ImportError is expected if Casbin is not installed
|
|
1315
|
+
logger.warning(
|
|
1316
|
+
f"❌ Casbin not available for '{slug}': {e}. "
|
|
1317
|
+
"Install with: pip install mdb-engine[casbin]"
|
|
1318
|
+
)
|
|
1319
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1320
|
+
logger.exception(f"❌ Failed to initialize Casbin provider for '{slug}': {e}")
|
|
1321
|
+
# Informational message, not exception logging
|
|
1322
|
+
logger.error( # noqa: TRY400
|
|
1323
|
+
f"❌ This means authorization will NOT work for '{slug}' - "
|
|
1324
|
+
f"app.state.authz_provider will remain None"
|
|
1325
|
+
)
|
|
1326
|
+
except (
|
|
1327
|
+
RuntimeError,
|
|
1328
|
+
ValueError,
|
|
1329
|
+
AttributeError,
|
|
1330
|
+
TypeError,
|
|
1331
|
+
ConnectionError,
|
|
1332
|
+
OSError,
|
|
1333
|
+
) as e:
|
|
1334
|
+
# Catch specific exceptions that might occur during initialization
|
|
1335
|
+
logger.exception(
|
|
1336
|
+
f"❌ Unexpected error initializing Casbin provider for '{slug}': {e}"
|
|
1337
|
+
)
|
|
1338
|
+
# Informational message, not exception logging
|
|
1339
|
+
logger.error( # noqa: TRY400
|
|
1340
|
+
f"❌ This means authorization will NOT work for '{slug}' - "
|
|
1341
|
+
f"app.state.authz_provider will remain None"
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
elif authz_provider_type is None and auth_policy:
|
|
1345
|
+
# Default to Casbin if provider not specified but auth.policy exists
|
|
1346
|
+
logger.info(
|
|
1347
|
+
f"⚠️ No provider specified in auth.policy for '{slug}', "
|
|
1348
|
+
f"defaulting to Casbin"
|
|
1349
|
+
)
|
|
1350
|
+
try:
|
|
1351
|
+
from ..auth.casbin_factory import initialize_casbin_from_manifest
|
|
1352
|
+
|
|
1353
|
+
authz_provider = await initialize_casbin_from_manifest(
|
|
1354
|
+
engine, slug, app_manifest
|
|
1355
|
+
)
|
|
1356
|
+
if authz_provider:
|
|
1357
|
+
app.state.authz_provider = authz_provider
|
|
1358
|
+
logger.info(f"✅ Casbin provider auto-initialized for '{slug}' (default)")
|
|
1359
|
+
else:
|
|
1360
|
+
logger.warning(
|
|
1361
|
+
f"⚠️ Casbin provider not initialized for '{slug}' "
|
|
1362
|
+
f"(default attempt failed)"
|
|
1363
|
+
)
|
|
1364
|
+
except ImportError as e:
|
|
1365
|
+
logger.warning(
|
|
1366
|
+
f"⚠️ Casbin not available for '{slug}': {e}. "
|
|
1367
|
+
"Install with: pip install mdb-engine[casbin]"
|
|
1368
|
+
)
|
|
1369
|
+
except (
|
|
1370
|
+
ValueError,
|
|
1371
|
+
TypeError,
|
|
1372
|
+
RuntimeError,
|
|
1373
|
+
AttributeError,
|
|
1374
|
+
KeyError,
|
|
1375
|
+
) as e:
|
|
1376
|
+
logger.exception(
|
|
1377
|
+
f"❌ Failed to initialize Casbin provider for '{slug}' (default): {e}"
|
|
1378
|
+
)
|
|
1379
|
+
elif authz_provider_type:
|
|
1380
|
+
logger.warning(
|
|
1381
|
+
f"⚠️ Unknown authz provider type '{authz_provider_type}' for '{slug}' - "
|
|
1382
|
+
f"skipping initialization"
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
# Auto-seed demo users if configured in manifest
|
|
1386
|
+
users_config = auth_config.get("users", {})
|
|
1387
|
+
if users_config.get("enabled") and users_config.get("demo_users"):
|
|
1388
|
+
try:
|
|
1389
|
+
from ..auth import ensure_demo_users_exist
|
|
1390
|
+
|
|
1391
|
+
db = engine.get_scoped_db(slug)
|
|
1392
|
+
demo_users = await ensure_demo_users_exist(
|
|
1393
|
+
db=db,
|
|
1394
|
+
slug_id=slug,
|
|
1395
|
+
config=app_manifest,
|
|
1396
|
+
)
|
|
1397
|
+
if demo_users:
|
|
1398
|
+
logger.info(f"✅ Seeded {len(demo_users)} demo user(s) for '{slug}'")
|
|
1399
|
+
except (
|
|
1400
|
+
ImportError,
|
|
1401
|
+
ValueError,
|
|
1402
|
+
TypeError,
|
|
1403
|
+
RuntimeError,
|
|
1404
|
+
AttributeError,
|
|
1405
|
+
KeyError,
|
|
1406
|
+
) as e:
|
|
1407
|
+
logger.warning(f"⚠️ Failed to seed demo users for '{slug}': {e}")
|
|
1408
|
+
|
|
1409
|
+
# Expose engine state on app.state
|
|
1410
|
+
app.state.engine = engine
|
|
1411
|
+
app.state.app_slug = slug
|
|
1412
|
+
app.state.manifest = app_manifest
|
|
1413
|
+
app.state.is_multi_site = is_multi_site
|
|
1414
|
+
app.state.auth_mode = auth_mode
|
|
1415
|
+
app.state.ray_actor = engine.ray_actor
|
|
1416
|
+
|
|
1417
|
+
# Initialize DI container (if not already set)
|
|
1418
|
+
from ..di import Container
|
|
1419
|
+
|
|
1420
|
+
if not hasattr(app.state, "container") or app.state.container is None:
|
|
1421
|
+
app.state.container = Container()
|
|
1422
|
+
logger.debug(f"DI Container initialized for '{slug}'")
|
|
1423
|
+
|
|
1424
|
+
# Call on_startup callback if provided
|
|
1425
|
+
if on_startup:
|
|
1426
|
+
try:
|
|
1427
|
+
await on_startup(app, engine, app_manifest)
|
|
1428
|
+
logger.info(f"on_startup callback completed for '{slug}'")
|
|
1429
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1430
|
+
logger.exception(f"on_startup callback failed for '{slug}': {e}")
|
|
1431
|
+
raise
|
|
1432
|
+
|
|
1433
|
+
yield
|
|
1434
|
+
|
|
1435
|
+
# Call on_shutdown callback if provided
|
|
1436
|
+
if on_shutdown:
|
|
1437
|
+
try:
|
|
1438
|
+
await on_shutdown(app, engine, app_manifest)
|
|
1439
|
+
logger.info(f"on_shutdown callback completed for '{slug}'")
|
|
1440
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1441
|
+
logger.warning(f"on_shutdown callback failed for '{slug}': {e}")
|
|
1442
|
+
|
|
1443
|
+
# Shutdown engine (skip if sub-app - parent manages lifecycle)
|
|
1444
|
+
if not is_sub_app:
|
|
1445
|
+
await engine.shutdown()
|
|
1446
|
+
|
|
1447
|
+
# Create FastAPI app
|
|
1448
|
+
app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
|
|
1449
|
+
|
|
1450
|
+
# Add request scope middleware (innermost layer - runs first on request)
|
|
1451
|
+
# This sets up the DI request scope for each request
|
|
1452
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
1453
|
+
|
|
1454
|
+
from ..di import ScopeManager
|
|
1455
|
+
|
|
1456
|
+
class RequestScopeMiddleware(BaseHTTPMiddleware):
|
|
1457
|
+
"""Middleware that manages request-scoped DI instances."""
|
|
1458
|
+
|
|
1459
|
+
async def dispatch(self, request, call_next):
|
|
1460
|
+
ScopeManager.begin_request()
|
|
1461
|
+
try:
|
|
1462
|
+
response = await call_next(request)
|
|
1463
|
+
return response
|
|
1464
|
+
finally:
|
|
1465
|
+
ScopeManager.end_request()
|
|
1466
|
+
|
|
1467
|
+
app.add_middleware(RequestScopeMiddleware)
|
|
1468
|
+
logger.debug(f"RequestScopeMiddleware added for '{slug}'")
|
|
1469
|
+
|
|
1470
|
+
# Add rate limiting middleware FIRST (outermost layer)
|
|
1471
|
+
# This ensures rate limiting happens before auth validation
|
|
1472
|
+
rate_limits_config = auth_config.get("rate_limits", {})
|
|
1473
|
+
if rate_limits_config or auth_mode == "shared":
|
|
1474
|
+
from ..auth.rate_limiter import create_rate_limit_middleware
|
|
1475
|
+
|
|
1476
|
+
rate_limit_middleware = create_rate_limit_middleware(
|
|
1477
|
+
manifest_auth=auth_config,
|
|
1478
|
+
)
|
|
1479
|
+
app.add_middleware(rate_limit_middleware)
|
|
1480
|
+
logger.info(
|
|
1481
|
+
f"AuthRateLimitMiddleware added for '{slug}' "
|
|
1482
|
+
f"(endpoints: {list(rate_limits_config.keys()) or 'defaults'})"
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
# Add shared auth middleware (after rate limiting)
|
|
1486
|
+
# Uses lazy version that reads user_pool from app.state
|
|
1487
|
+
if auth_mode == "shared":
|
|
1488
|
+
from ..auth.shared_middleware import create_shared_auth_middleware_lazy
|
|
1489
|
+
|
|
1490
|
+
middleware_class = create_shared_auth_middleware_lazy(
|
|
1491
|
+
app_slug=slug,
|
|
1492
|
+
manifest_auth=auth_config,
|
|
1493
|
+
)
|
|
1494
|
+
app.add_middleware(middleware_class)
|
|
1495
|
+
logger.info(
|
|
1496
|
+
f"LazySharedAuthMiddleware added for '{slug}' "
|
|
1497
|
+
f"(require_role={auth_config.get('require_role')})"
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
# Add CSRF middleware (after auth - auto-enabled for shared mode)
|
|
1501
|
+
# CSRF protection is enabled by default for shared auth mode
|
|
1502
|
+
# SKIP for sub-apps in multi-app setups - parent app handles CSRF
|
|
1503
|
+
csrf_config = auth_config.get("csrf_protection", True if auth_mode == "shared" else False)
|
|
1504
|
+
if csrf_config and not is_sub_app: # Don't add CSRF to child apps
|
|
1505
|
+
from ..auth.csrf import create_csrf_middleware
|
|
1506
|
+
|
|
1507
|
+
csrf_middleware = create_csrf_middleware(
|
|
1508
|
+
manifest_auth=auth_config,
|
|
1509
|
+
)
|
|
1510
|
+
app.add_middleware(csrf_middleware)
|
|
1511
|
+
logger.info(f"CSRFMiddleware added for '{slug}'")
|
|
1512
|
+
elif csrf_config and is_sub_app:
|
|
1513
|
+
logger.debug(
|
|
1514
|
+
f"CSRFMiddleware skipped for child app '{slug}' - "
|
|
1515
|
+
f"parent app handles CSRF protection for WebSocket routes"
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
# Add security middleware (HSTS, headers)
|
|
1519
|
+
security_config = auth_config.get("security", {})
|
|
1520
|
+
hsts_config = security_config.get("hsts", {})
|
|
1521
|
+
if hsts_config.get("enabled", True) or auth_mode == "shared":
|
|
1522
|
+
from ..auth.middleware import SecurityMiddleware
|
|
1523
|
+
|
|
1524
|
+
app.add_middleware(
|
|
1525
|
+
SecurityMiddleware,
|
|
1526
|
+
require_https=False, # HSTS handles this in production
|
|
1527
|
+
csrf_protection=False, # Handled by CSRFMiddleware above
|
|
1528
|
+
security_headers=True,
|
|
1529
|
+
hsts_config=hsts_config,
|
|
1530
|
+
)
|
|
1531
|
+
logger.info(f"SecurityMiddleware added for '{slug}'")
|
|
1532
|
+
|
|
1533
|
+
logger.debug(f"FastAPI app created for '{slug}'")
|
|
1534
|
+
|
|
1535
|
+
return app
|
|
1536
|
+
|
|
1537
|
+
def _validate_path_prefixes(self, apps: list[dict[str, Any]]) -> tuple[bool, list[str]]:
|
|
1538
|
+
"""
|
|
1539
|
+
Validate path prefixes for multi-app mounting.
|
|
1540
|
+
|
|
1541
|
+
Checks:
|
|
1542
|
+
- All prefixes start with '/'
|
|
1543
|
+
- No prefix is a prefix of another (e.g., '/app' conflicts with '/app/v2')
|
|
1544
|
+
- No conflicts with reserved paths ('/health', '/docs', '/openapi.json', '/_mdb')
|
|
1545
|
+
- Slug matches manifest slug (if manifest is readable)
|
|
1546
|
+
|
|
1547
|
+
Args:
|
|
1548
|
+
apps: List of app configs with 'path_prefix' keys
|
|
1549
|
+
|
|
1550
|
+
Returns:
|
|
1551
|
+
Tuple of (is_valid, list_of_errors)
|
|
1552
|
+
"""
|
|
1553
|
+
|
|
1554
|
+
errors: list[str] = []
|
|
1555
|
+
reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc", "/_mdb"}
|
|
1556
|
+
|
|
1557
|
+
# Extract path prefixes
|
|
1558
|
+
path_prefixes: list[str] = []
|
|
1559
|
+
for app_config in apps:
|
|
1560
|
+
slug = app_config.get("slug", "unknown")
|
|
1561
|
+
path_prefix = app_config.get("path_prefix", f"/{slug}")
|
|
1562
|
+
|
|
1563
|
+
if not path_prefix.startswith("/"):
|
|
1564
|
+
errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: '{slug}')")
|
|
1565
|
+
continue
|
|
1566
|
+
|
|
1567
|
+
# Check for common mistakes
|
|
1568
|
+
if path_prefix.endswith("/") and path_prefix != "/":
|
|
1569
|
+
logger.warning(
|
|
1570
|
+
f"Path prefix '{path_prefix}' ends with '/'. "
|
|
1571
|
+
f"Consider removing trailing slash for app '{slug}'"
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
path_prefixes.append(path_prefix)
|
|
1575
|
+
|
|
1576
|
+
# Check for conflicts with reserved paths
|
|
1577
|
+
for prefix in path_prefixes:
|
|
1578
|
+
if prefix in reserved_paths:
|
|
1579
|
+
errors.append(
|
|
1580
|
+
f"Path prefix '{prefix}' conflicts with reserved path. "
|
|
1581
|
+
"Reserved paths: /health, /docs, /openapi.json, /redoc, /_mdb"
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
# Check for prefix conflicts (one prefix being a prefix of another)
|
|
1585
|
+
path_prefixes_sorted = sorted(path_prefixes)
|
|
1586
|
+
for i, prefix1 in enumerate(path_prefixes_sorted):
|
|
1587
|
+
for prefix2 in path_prefixes_sorted[i + 1 :]:
|
|
1588
|
+
# Normalize by ensuring both end with / for comparison
|
|
1589
|
+
p1_norm = prefix1 if prefix1.endswith("/") else prefix1 + "/"
|
|
1590
|
+
p2_norm = prefix2 if prefix2.endswith("/") else prefix2 + "/"
|
|
1591
|
+
|
|
1592
|
+
if p1_norm.startswith(p2_norm) or p2_norm.startswith(p1_norm):
|
|
1593
|
+
# Find which apps these belong to for better error message
|
|
1594
|
+
app1_slug = next(
|
|
1595
|
+
(a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix1),
|
|
1596
|
+
"unknown",
|
|
1597
|
+
)
|
|
1598
|
+
app2_slug = next(
|
|
1599
|
+
(a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix2),
|
|
1600
|
+
"unknown",
|
|
1601
|
+
)
|
|
1602
|
+
errors.append(
|
|
1603
|
+
f"Path prefix conflict: '{prefix1}' (app: '{app1_slug}') and "
|
|
1604
|
+
f"'{prefix2}' (app: '{app2_slug}') overlap. "
|
|
1605
|
+
"One cannot be a prefix of another."
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
# Check for duplicates
|
|
1609
|
+
if len(path_prefixes) != len(set(path_prefixes)):
|
|
1610
|
+
seen = {}
|
|
1611
|
+
for app_config in apps:
|
|
1612
|
+
prefix = app_config.get("path_prefix")
|
|
1613
|
+
slug = app_config.get("slug", "unknown")
|
|
1614
|
+
if prefix in seen:
|
|
1615
|
+
first_slug = seen[prefix]
|
|
1616
|
+
errors.append(
|
|
1617
|
+
f"Duplicate path prefix: '{prefix}' used by both "
|
|
1618
|
+
f"'{first_slug}' and '{slug}'"
|
|
1619
|
+
)
|
|
1620
|
+
else:
|
|
1621
|
+
seen[prefix] = slug
|
|
1622
|
+
|
|
1623
|
+
return len(errors) == 0, errors
|
|
1624
|
+
|
|
1625
|
+
def _discover_apps_from_directory(
|
|
1626
|
+
self,
|
|
1627
|
+
apps_dir: Path,
|
|
1628
|
+
path_prefix_template: str | None = None,
|
|
1629
|
+
) -> list[dict[str, Any]]:
|
|
1630
|
+
"""
|
|
1631
|
+
Auto-discover apps by scanning directory for manifest.json files.
|
|
1632
|
+
|
|
1633
|
+
Args:
|
|
1634
|
+
apps_dir: Directory to scan for apps
|
|
1635
|
+
path_prefix_template: Template for path prefixes (e.g., "/app-{index}")
|
|
1636
|
+
|
|
1637
|
+
Returns:
|
|
1638
|
+
List of app configurations
|
|
1639
|
+
"""
|
|
1640
|
+
import json
|
|
1641
|
+
|
|
1642
|
+
apps_dir = Path(apps_dir)
|
|
1643
|
+
if not apps_dir.exists():
|
|
1644
|
+
raise ValueError(f"Apps directory does not exist: {apps_dir}")
|
|
1645
|
+
|
|
1646
|
+
discovered_apps = []
|
|
1647
|
+
manifest_files = list(apps_dir.rglob("manifest.json"))
|
|
1648
|
+
|
|
1649
|
+
if not manifest_files:
|
|
1650
|
+
raise ValueError(f"No manifest.json files found in {apps_dir}")
|
|
1651
|
+
|
|
1652
|
+
for idx, manifest_path in enumerate(sorted(manifest_files), start=1):
|
|
1653
|
+
try:
|
|
1654
|
+
with open(manifest_path) as f:
|
|
1655
|
+
manifest_data = json.load(f)
|
|
1656
|
+
|
|
1657
|
+
slug = manifest_data.get("slug")
|
|
1658
|
+
if not slug:
|
|
1659
|
+
logger.warning(f"Skipping manifest without slug: {manifest_path}")
|
|
1660
|
+
continue
|
|
1661
|
+
|
|
1662
|
+
# Generate path prefix
|
|
1663
|
+
if path_prefix_template:
|
|
1664
|
+
path_prefix = path_prefix_template.format(index=idx, slug=slug)
|
|
1665
|
+
else:
|
|
1666
|
+
path_prefix = f"/{slug}"
|
|
1667
|
+
|
|
1668
|
+
discovered_apps.append(
|
|
1669
|
+
{
|
|
1670
|
+
"slug": slug,
|
|
1671
|
+
"manifest": manifest_path,
|
|
1672
|
+
"path_prefix": path_prefix,
|
|
1673
|
+
}
|
|
1674
|
+
)
|
|
1675
|
+
logger.info(
|
|
1676
|
+
f"Discovered app '{slug}' at {manifest_path} " f"(will mount at {path_prefix})"
|
|
1677
|
+
)
|
|
1678
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
1679
|
+
logger.warning(f"Failed to read manifest at {manifest_path}: {e}")
|
|
1680
|
+
continue
|
|
1681
|
+
|
|
1682
|
+
if not discovered_apps:
|
|
1683
|
+
raise ValueError(f"No valid apps discovered in {apps_dir}")
|
|
1684
|
+
|
|
1685
|
+
return discovered_apps
|
|
1686
|
+
|
|
1687
|
+
def _validate_manifests(self, apps: list[dict[str, Any]], strict: bool) -> list[str]:
|
|
1688
|
+
"""Validate all app manifests."""
|
|
1689
|
+
import json
|
|
1690
|
+
|
|
1691
|
+
logger.info("Validating all manifests before mounting...")
|
|
1692
|
+
validation_errors = []
|
|
1693
|
+
for app_config in apps:
|
|
1694
|
+
slug = app_config.get("slug", "unknown")
|
|
1695
|
+
manifest_path = app_config.get("manifest")
|
|
1696
|
+
try:
|
|
1697
|
+
with open(manifest_path) as f:
|
|
1698
|
+
manifest_data = json.load(f)
|
|
1699
|
+
|
|
1700
|
+
# Validate manifest
|
|
1701
|
+
from .manifest import validate_manifest
|
|
1702
|
+
|
|
1703
|
+
is_valid, error_msg, error_paths = validate_manifest(manifest_data)
|
|
1704
|
+
|
|
1705
|
+
if not is_valid:
|
|
1706
|
+
error_detail = f"App '{slug}' at {manifest_path}: {error_msg}"
|
|
1707
|
+
if error_paths:
|
|
1708
|
+
error_detail += f" (paths: {', '.join(error_paths)})"
|
|
1709
|
+
validation_errors.append(error_detail)
|
|
1710
|
+
if strict:
|
|
1711
|
+
raise ValueError(
|
|
1712
|
+
f"Manifest validation failed for app '{slug}': {error_msg}"
|
|
1713
|
+
) from None
|
|
1714
|
+
|
|
1715
|
+
# Validate slug matches manifest slug
|
|
1716
|
+
manifest_slug = manifest_data.get("slug")
|
|
1717
|
+
if manifest_slug and manifest_slug != slug:
|
|
1718
|
+
error_msg = (
|
|
1719
|
+
f"Slug mismatch: config slug '{slug}' does not match "
|
|
1720
|
+
f"manifest slug '{manifest_slug}' in {manifest_path}"
|
|
1721
|
+
)
|
|
1722
|
+
validation_errors.append(error_msg)
|
|
1723
|
+
if strict:
|
|
1724
|
+
raise ValueError(error_msg) from None
|
|
1725
|
+
except FileNotFoundError as e:
|
|
1726
|
+
error_msg = f"Manifest file not found for app '{slug}': {manifest_path}"
|
|
1727
|
+
validation_errors.append(error_msg)
|
|
1728
|
+
if strict:
|
|
1729
|
+
raise ValueError(error_msg) from e
|
|
1730
|
+
except json.JSONDecodeError as e:
|
|
1731
|
+
error_msg = f"Invalid JSON in manifest for app '{slug}' at {manifest_path}: {e}"
|
|
1732
|
+
validation_errors.append(error_msg)
|
|
1733
|
+
if strict:
|
|
1734
|
+
raise ValueError(error_msg) from e
|
|
1735
|
+
|
|
1736
|
+
return validation_errors
|
|
1737
|
+
|
|
1738
|
+
def _import_app_routes(self, child_app: "FastAPI", manifest_path: Path, slug: str) -> None:
|
|
1739
|
+
"""
|
|
1740
|
+
Automatically discover and import route modules for a child app.
|
|
1741
|
+
|
|
1742
|
+
This method looks for route modules (web.py, routes.py) in the same directory
|
|
1743
|
+
as the manifest and imports them so that route decorators are executed and
|
|
1744
|
+
routes are registered on the child app.
|
|
1745
|
+
|
|
1746
|
+
Args:
|
|
1747
|
+
child_app: The FastAPI child app to register routes on
|
|
1748
|
+
manifest_path: Path to the manifest.json file
|
|
1749
|
+
slug: App slug for logging
|
|
1750
|
+
|
|
1751
|
+
The method tries multiple strategies:
|
|
1752
|
+
1. Look for 'web.py' in the manifest directory
|
|
1753
|
+
2. Look for 'routes.py' in the manifest directory
|
|
1754
|
+
3. Check manifest for explicit 'routes_module' field (future support)
|
|
1755
|
+
|
|
1756
|
+
When importing, the method ensures that route decorators in the imported module
|
|
1757
|
+
reference the child_app by temporarily injecting it into the module namespace.
|
|
1758
|
+
"""
|
|
1759
|
+
import importlib.util
|
|
1760
|
+
import sys
|
|
1761
|
+
|
|
1762
|
+
manifest_dir = manifest_path.parent
|
|
1763
|
+
|
|
1764
|
+
# Try to find route modules in order of preference
|
|
1765
|
+
route_module_paths = [
|
|
1766
|
+
manifest_dir / "web.py",
|
|
1767
|
+
manifest_dir / "routes.py",
|
|
1768
|
+
]
|
|
1769
|
+
|
|
1770
|
+
# Also check for routes_module in manifest (future support)
|
|
1771
|
+
try:
|
|
1772
|
+
import json
|
|
1773
|
+
|
|
1774
|
+
with open(manifest_path) as f:
|
|
1775
|
+
manifest_data = json.load(f)
|
|
1776
|
+
routes_module = manifest_data.get("routes_module")
|
|
1777
|
+
if routes_module:
|
|
1778
|
+
# Support both relative (to manifest dir) and absolute paths
|
|
1779
|
+
if routes_module.startswith("/"):
|
|
1780
|
+
route_module_paths.insert(0, Path(routes_module))
|
|
1781
|
+
else:
|
|
1782
|
+
route_module_paths.insert(0, manifest_dir / routes_module)
|
|
1783
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
1784
|
+
pass
|
|
1785
|
+
|
|
1786
|
+
imported = False
|
|
1787
|
+
module_name = None
|
|
1788
|
+
route_module = None
|
|
1789
|
+
manifest_dir_str = None
|
|
1790
|
+
path_inserted = False
|
|
1791
|
+
|
|
1792
|
+
for route_module_path in route_module_paths:
|
|
1793
|
+
if not route_module_path.exists():
|
|
1794
|
+
continue
|
|
1795
|
+
|
|
1796
|
+
# Create a unique module name to avoid conflicts
|
|
1797
|
+
module_name = f"mdb_engine_imported_routes_{slug}_{id(child_app)}"
|
|
1798
|
+
|
|
1799
|
+
try:
|
|
1800
|
+
# Validate file is actually a Python file
|
|
1801
|
+
if not route_module_path.suffix == ".py":
|
|
1802
|
+
logger.debug(f"Skipping non-Python file '{route_module_path}' for app '{slug}'")
|
|
1803
|
+
continue
|
|
1804
|
+
|
|
1805
|
+
# Load the module spec
|
|
1806
|
+
spec = importlib.util.spec_from_file_location(module_name, route_module_path)
|
|
1807
|
+
if spec is None or spec.loader is None:
|
|
1808
|
+
logger.warning(
|
|
1809
|
+
f"Could not create spec for route module '{route_module_path}' "
|
|
1810
|
+
f"for app '{slug}'"
|
|
1811
|
+
)
|
|
1812
|
+
continue
|
|
1813
|
+
|
|
1814
|
+
route_module = importlib.util.module_from_spec(spec)
|
|
1815
|
+
|
|
1816
|
+
# CRITICAL: Inject child_app into module namespace BEFORE loading
|
|
1817
|
+
# This ensures that @app.get(), @app.post(), etc. decorators in the
|
|
1818
|
+
# imported module will reference our child_app instead of creating a new one
|
|
1819
|
+
route_module.app = child_app
|
|
1820
|
+
route_module.engine = self # Also provide engine reference for dependencies
|
|
1821
|
+
|
|
1822
|
+
# Add to sys.modules temporarily to handle relative imports
|
|
1823
|
+
# Use a try-finally to ensure cleanup even on exceptions
|
|
1824
|
+
sys.modules[module_name] = route_module
|
|
1825
|
+
|
|
1826
|
+
# Store route count before import
|
|
1827
|
+
routes_before = len(child_app.routes)
|
|
1828
|
+
|
|
1829
|
+
# Add manifest directory to Python path temporarily for relative imports
|
|
1830
|
+
# This allows route modules to import sibling modules
|
|
1831
|
+
manifest_dir_str = str(manifest_dir.resolve())
|
|
1832
|
+
path_inserted = manifest_dir_str not in sys.path
|
|
1833
|
+
if path_inserted:
|
|
1834
|
+
sys.path.insert(0, manifest_dir_str)
|
|
1835
|
+
|
|
1836
|
+
try:
|
|
1837
|
+
# Execute the module (runs route decorators with injected app)
|
|
1838
|
+
spec.loader.exec_module(route_module)
|
|
1839
|
+
except SyntaxError as e:
|
|
1840
|
+
logger.warning(
|
|
1841
|
+
f"Syntax error in route module '{route_module_path}' "
|
|
1842
|
+
f"for app '{slug}': {e}. Skipping this module."
|
|
1843
|
+
)
|
|
1844
|
+
continue
|
|
1845
|
+
except ImportError as e:
|
|
1846
|
+
# ImportError might be due to missing dependencies - log but don't fail
|
|
1847
|
+
logger.debug(
|
|
1848
|
+
f"Import error in route module '{route_module_path}' "
|
|
1849
|
+
f"for app '{slug}': {e}. "
|
|
1850
|
+
"This may be OK if dependencies are optional."
|
|
1851
|
+
)
|
|
1852
|
+
# Check if it's a critical import (like FastAPI) vs optional dependency
|
|
1853
|
+
error_str = str(e).lower()
|
|
1854
|
+
if "fastapi" in error_str or "starlette" in error_str:
|
|
1855
|
+
logger.warning(
|
|
1856
|
+
f"Route module '{route_module_path}' for app '{slug}' "
|
|
1857
|
+
"requires FastAPI/Starlette but they're not available. "
|
|
1858
|
+
"Routes will not be registered."
|
|
1859
|
+
)
|
|
1860
|
+
continue
|
|
1861
|
+
finally:
|
|
1862
|
+
# Remove from path only if we added it
|
|
1863
|
+
if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
|
|
1864
|
+
try:
|
|
1865
|
+
sys.path.remove(manifest_dir_str)
|
|
1866
|
+
except ValueError:
|
|
1867
|
+
# Path might have been removed already - ignore
|
|
1868
|
+
pass
|
|
1869
|
+
|
|
1870
|
+
# Check if module overwrote app (shouldn't happen in well-structured modules)
|
|
1871
|
+
module_app = getattr(route_module, "app", None)
|
|
1872
|
+
if module_app is not None and module_app is not child_app:
|
|
1873
|
+
import warnings
|
|
1874
|
+
|
|
1875
|
+
warning_msg = (
|
|
1876
|
+
f"Route module '{route_module_path.name}' for app '{slug}' "
|
|
1877
|
+
"created its own app instance. Routes defined before app creation "
|
|
1878
|
+
"are registered, but routes defined after may not be. "
|
|
1879
|
+
"Consider restructuring the module to use the injected 'app' variable."
|
|
1880
|
+
)
|
|
1881
|
+
logger.warning(warning_msg)
|
|
1882
|
+
warnings.warn(warning_msg, UserWarning, stacklevel=2)
|
|
1883
|
+
|
|
1884
|
+
routes_after = len(child_app.routes)
|
|
1885
|
+
routes_added = routes_after - routes_before
|
|
1886
|
+
|
|
1887
|
+
if routes_added > 0:
|
|
1888
|
+
logger.info(
|
|
1889
|
+
f"✅ Auto-imported routes from '{route_module_path.name}' "
|
|
1890
|
+
f"for app '{slug}'. Added {routes_added} route(s) "
|
|
1891
|
+
f"(total: {routes_after})"
|
|
1892
|
+
)
|
|
1893
|
+
else:
|
|
1894
|
+
logger.debug(
|
|
1895
|
+
f"Route module '{route_module_path.name}' for app '{slug}' "
|
|
1896
|
+
"was imported but no new routes were registered. "
|
|
1897
|
+
"This may be expected if routes are registered conditionally."
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
imported = True
|
|
1901
|
+
break
|
|
1902
|
+
|
|
1903
|
+
except (ValueError, TypeError, AttributeError, RuntimeError, OSError) as e:
|
|
1904
|
+
logger.warning(
|
|
1905
|
+
f"Unexpected error importing route module '{route_module_path}' "
|
|
1906
|
+
f"for app '{slug}': {e}",
|
|
1907
|
+
exc_info=True,
|
|
1908
|
+
)
|
|
1909
|
+
continue
|
|
1910
|
+
finally:
|
|
1911
|
+
# Clean up temporary module from sys.modules
|
|
1912
|
+
if module_name and module_name in sys.modules:
|
|
1913
|
+
try:
|
|
1914
|
+
del sys.modules[module_name]
|
|
1915
|
+
except KeyError:
|
|
1916
|
+
# Already removed - ignore
|
|
1917
|
+
pass
|
|
1918
|
+
# Ensure path is cleaned up even if exception occurred
|
|
1919
|
+
if path_inserted and manifest_dir_str and manifest_dir_str in sys.path:
|
|
1920
|
+
try:
|
|
1921
|
+
sys.path.remove(manifest_dir_str)
|
|
1922
|
+
except ValueError:
|
|
1923
|
+
pass
|
|
1924
|
+
|
|
1925
|
+
if not imported:
|
|
1926
|
+
logger.debug(
|
|
1927
|
+
f"No route modules found for app '{slug}' in {manifest_dir}. "
|
|
1928
|
+
"Routes may be defined elsewhere or app may not have HTTP routes."
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
def create_multi_app( # noqa: C901
|
|
1932
|
+
self,
|
|
1933
|
+
apps: list[dict[str, Any]] | None = None,
|
|
1934
|
+
multi_app_manifest: Path | None = None,
|
|
1935
|
+
apps_dir: Path | None = None,
|
|
1936
|
+
path_prefix_template: str | None = None,
|
|
1937
|
+
validate: bool = False,
|
|
1938
|
+
strict: bool = False,
|
|
1939
|
+
title: str = "Multi-App API",
|
|
1940
|
+
root_path: str = "",
|
|
1941
|
+
**fastapi_kwargs: Any,
|
|
1942
|
+
) -> "FastAPI":
|
|
1943
|
+
"""
|
|
1944
|
+
Create a parent FastAPI app that mounts multiple child apps.
|
|
1945
|
+
|
|
1946
|
+
Each child app is mounted at a path prefix (e.g., /auth-hub, /pwd-zero) and
|
|
1947
|
+
maintains its own routes, middleware, and state while sharing the engine instance.
|
|
1948
|
+
|
|
1949
|
+
Args:
|
|
1950
|
+
apps: List of app configurations. Each dict should have:
|
|
1951
|
+
- slug: App slug (required)
|
|
1952
|
+
- manifest: Path to manifest.json (required)
|
|
1953
|
+
- path_prefix: Optional path prefix (defaults to /{slug})
|
|
1954
|
+
- on_startup: Optional startup callback function
|
|
1955
|
+
- on_shutdown: Optional shutdown callback function
|
|
1956
|
+
multi_app_manifest: Path to a multi-app manifest.json that defines all apps.
|
|
1957
|
+
Format:
|
|
1958
|
+
{
|
|
1959
|
+
"multi_app": {
|
|
1960
|
+
"enabled": true,
|
|
1961
|
+
"apps": [
|
|
1962
|
+
{
|
|
1963
|
+
"slug": "app1",
|
|
1964
|
+
"manifest": "./app1/manifest.json",
|
|
1965
|
+
"path_prefix": "/app1",
|
|
1966
|
+
}
|
|
1967
|
+
]
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
apps_dir: Directory to scan for apps (auto-discovery). If provided and
|
|
1971
|
+
apps is None, will recursively scan for manifest.json files and
|
|
1972
|
+
auto-discover apps. Takes precedence over multi_app_manifest.
|
|
1973
|
+
path_prefix_template: Template for auto-generated path prefixes when using
|
|
1974
|
+
apps_dir. Use {index} for app index and {slug} for app slug.
|
|
1975
|
+
Example: "/app-{index}" or "/{slug}"
|
|
1976
|
+
validate: If True, validate all manifests before mounting (default: False)
|
|
1977
|
+
strict: If True, fail fast on any validation error (default: False).
|
|
1978
|
+
Only used when validate=True.
|
|
1979
|
+
title: Title for the parent FastAPI app
|
|
1980
|
+
root_path: Root path prefix for all mounted apps (optional)
|
|
1981
|
+
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1982
|
+
|
|
1983
|
+
Returns:
|
|
1984
|
+
Parent FastAPI application with all child apps mounted
|
|
1985
|
+
|
|
1986
|
+
Raises:
|
|
1987
|
+
ValueError: If configuration is invalid or path prefixes conflict
|
|
1988
|
+
RuntimeError: If engine is not initialized
|
|
1989
|
+
|
|
1990
|
+
Features:
|
|
1991
|
+
- Built-in app context helpers: Each mounted app has access to:
|
|
1992
|
+
- request.state.app_base_path: Path prefix (e.g., "/app-1")
|
|
1993
|
+
- request.state.auth_hub_url: Auth hub URL from manifest or env
|
|
1994
|
+
- request.state.app_slug: App slug
|
|
1995
|
+
- request.state.mounted_apps: Dict of all mounted apps with paths
|
|
1996
|
+
- request.state.engine: MongoDBEngine instance
|
|
1997
|
+
- request.state.manifest: App's manifest.json
|
|
1998
|
+
- Unified health check: GET /health aggregates health from all apps
|
|
1999
|
+
- Route introspection: GET /_mdb/routes lists all routes from all apps
|
|
2000
|
+
- OpenAPI aggregation: /docs combines docs from all apps
|
|
2001
|
+
- Per-app docs: /docs/{app_slug} for individual app documentation
|
|
2002
|
+
|
|
2003
|
+
Example:
|
|
2004
|
+
# Programmatic approach
|
|
2005
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
2006
|
+
app = engine.create_multi_app(
|
|
2007
|
+
apps=[
|
|
2008
|
+
{
|
|
2009
|
+
"slug": "auth-hub",
|
|
2010
|
+
"manifest": Path("./auth-hub/manifest.json"),
|
|
2011
|
+
"path_prefix": "/auth-hub",
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
"slug": "pwd-zero",
|
|
2015
|
+
"manifest": Path("./pwd-zero/manifest.json"),
|
|
2016
|
+
"path_prefix": "/pwd-zero",
|
|
2017
|
+
},
|
|
2018
|
+
]
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
# Manifest-based approach
|
|
2022
|
+
app = engine.create_multi_app(
|
|
2023
|
+
multi_app_manifest=Path("./multi_app_manifest.json")
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
# Auto-discovery approach
|
|
2027
|
+
app = engine.create_multi_app(
|
|
2028
|
+
apps_dir=Path("./apps"),
|
|
2029
|
+
path_prefix_template="/app-{index}",
|
|
2030
|
+
validate=True,
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
# Access app context in routes
|
|
2034
|
+
@app.get("/my-route")
|
|
2035
|
+
async def my_route(request: Request):
|
|
2036
|
+
base_path = request.state.app_base_path # "/app-1"
|
|
2037
|
+
auth_url = request.state.auth_hub_url # "/auth-hub"
|
|
2038
|
+
slug = request.state.app_slug # "my-app"
|
|
2039
|
+
all_apps = request.state.mounted_apps # Dict of all apps
|
|
2040
|
+
"""
|
|
2041
|
+
import json
|
|
2042
|
+
|
|
2043
|
+
from fastapi import FastAPI
|
|
2044
|
+
|
|
2045
|
+
engine = self
|
|
2046
|
+
|
|
2047
|
+
# Auto-discovery: if apps_dir is provided and apps is None, discover apps
|
|
2048
|
+
if apps_dir and apps is None:
|
|
2049
|
+
logger.info(f"Auto-discovering apps from directory: {apps_dir}")
|
|
2050
|
+
apps = self._discover_apps_from_directory(
|
|
2051
|
+
apps_dir=apps_dir,
|
|
2052
|
+
path_prefix_template=path_prefix_template,
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
# Load configuration from manifest or apps parameter
|
|
2056
|
+
if multi_app_manifest:
|
|
2057
|
+
manifest_path = Path(multi_app_manifest)
|
|
2058
|
+
with open(manifest_path) as f:
|
|
2059
|
+
multi_app_config = json.load(f)
|
|
2060
|
+
|
|
2061
|
+
multi_app_section = multi_app_config.get("multi_app", {})
|
|
2062
|
+
if not multi_app_section.get("enabled", False):
|
|
2063
|
+
raise ValueError(
|
|
2064
|
+
"multi_app.enabled must be True in multi_app_manifest to use multi-app mode"
|
|
2065
|
+
)
|
|
2066
|
+
|
|
2067
|
+
apps_config = multi_app_section.get("apps", [])
|
|
2068
|
+
if not apps_config:
|
|
2069
|
+
raise ValueError("multi_app.apps must contain at least one app")
|
|
2070
|
+
|
|
2071
|
+
# Resolve manifest paths relative to multi_app_manifest location
|
|
2072
|
+
manifest_dir = manifest_path.parent
|
|
2073
|
+
apps = []
|
|
2074
|
+
for app_config in apps_config:
|
|
2075
|
+
manifest_rel_path = app_config.get("manifest")
|
|
2076
|
+
if not manifest_rel_path:
|
|
2077
|
+
raise ValueError(f"App '{app_config.get('slug')}' missing 'manifest' field")
|
|
2078
|
+
|
|
2079
|
+
# Resolve relative to multi_app_manifest location
|
|
2080
|
+
manifest_full_path = (manifest_dir / manifest_rel_path).resolve()
|
|
2081
|
+
slug = app_config.get("slug")
|
|
2082
|
+
path_prefix = app_config.get("path_prefix", f"/{slug}")
|
|
2083
|
+
|
|
2084
|
+
apps.append(
|
|
2085
|
+
{
|
|
2086
|
+
"slug": slug,
|
|
2087
|
+
"manifest": manifest_full_path,
|
|
2088
|
+
"path_prefix": path_prefix,
|
|
2089
|
+
}
|
|
2090
|
+
)
|
|
2091
|
+
|
|
2092
|
+
elif apps is not None:
|
|
2093
|
+
apps_config = apps
|
|
2094
|
+
# Convert Path objects to Path if they're strings
|
|
2095
|
+
apps = []
|
|
2096
|
+
for app_config in apps_config:
|
|
2097
|
+
manifest = app_config.get("manifest")
|
|
2098
|
+
if isinstance(manifest, str):
|
|
2099
|
+
manifest = Path(manifest)
|
|
2100
|
+
apps.append(
|
|
2101
|
+
{
|
|
2102
|
+
"slug": app_config.get("slug"),
|
|
2103
|
+
"manifest": manifest,
|
|
2104
|
+
"path_prefix": app_config.get("path_prefix", f"/{app_config.get('slug')}"),
|
|
2105
|
+
"on_startup": app_config.get("on_startup"),
|
|
2106
|
+
"on_shutdown": app_config.get("on_shutdown"),
|
|
2107
|
+
}
|
|
2108
|
+
)
|
|
2109
|
+
else:
|
|
2110
|
+
raise ValueError("Either 'apps', 'multi_app_manifest', or 'apps_dir' must be provided")
|
|
2111
|
+
|
|
2112
|
+
if not apps:
|
|
2113
|
+
raise ValueError("At least one app must be configured")
|
|
2114
|
+
|
|
2115
|
+
# Validate manifests if requested
|
|
2116
|
+
if validate:
|
|
2117
|
+
validation_errors = self._validate_manifests(apps, strict)
|
|
2118
|
+
if validation_errors:
|
|
2119
|
+
logger.warning(
|
|
2120
|
+
"Manifest validation found issues:\n"
|
|
2121
|
+
+ "\n".join(f" - {e}" for e in validation_errors)
|
|
2122
|
+
)
|
|
2123
|
+
if strict:
|
|
2124
|
+
raise ValueError(
|
|
2125
|
+
"Manifest validation failed (strict mode):\n"
|
|
2126
|
+
+ "\n".join(f" - {e}" for e in validation_errors)
|
|
2127
|
+
)
|
|
2128
|
+
|
|
2129
|
+
# Validate path prefixes (enhanced)
|
|
2130
|
+
is_valid, errors = self._validate_path_prefixes(apps)
|
|
2131
|
+
if not is_valid:
|
|
2132
|
+
raise ValueError(
|
|
2133
|
+
"Path prefix validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
|
2134
|
+
)
|
|
2135
|
+
|
|
2136
|
+
# Check if any app uses shared auth and collect public routes for CSRF exemption
|
|
2137
|
+
has_shared_auth = False
|
|
2138
|
+
all_public_routes = [
|
|
2139
|
+
"/health",
|
|
2140
|
+
"/docs",
|
|
2141
|
+
"/openapi.json",
|
|
2142
|
+
"/_mdb/routes",
|
|
2143
|
+
] # Base exempt routes
|
|
2144
|
+
for app_config in apps:
|
|
2145
|
+
try:
|
|
2146
|
+
manifest_path = app_config["manifest"]
|
|
2147
|
+
path_prefix = app_config.get("path_prefix", f"/{app_config.get('slug')}")
|
|
2148
|
+
with open(manifest_path) as f:
|
|
2149
|
+
app_manifest_pre = json.load(f)
|
|
2150
|
+
auth_config = app_manifest_pre.get("auth", {})
|
|
2151
|
+
if auth_config.get("mode") == "shared":
|
|
2152
|
+
has_shared_auth = True
|
|
2153
|
+
# Collect public routes with path prefix for CSRF exemption
|
|
2154
|
+
child_public_routes = auth_config.get("public_routes", [])
|
|
2155
|
+
for route in child_public_routes:
|
|
2156
|
+
# Add path prefix to make route absolute on parent app
|
|
2157
|
+
if route.startswith("/"):
|
|
2158
|
+
prefixed_route = f"{path_prefix.rstrip('/')}{route}"
|
|
2159
|
+
else:
|
|
2160
|
+
prefixed_route = f"{path_prefix.rstrip('/')}/{route}"
|
|
2161
|
+
if prefixed_route not in all_public_routes:
|
|
2162
|
+
all_public_routes.append(prefixed_route)
|
|
2163
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
2164
|
+
logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
|
|
2165
|
+
|
|
2166
|
+
# Validate hooks before creating lifespan (fail fast)
|
|
2167
|
+
for app_config in apps:
|
|
2168
|
+
slug = app_config.get("slug", "unknown")
|
|
2169
|
+
on_startup = app_config.get("on_startup")
|
|
2170
|
+
on_shutdown = app_config.get("on_shutdown")
|
|
2171
|
+
|
|
2172
|
+
if on_startup is not None and not callable(on_startup):
|
|
2173
|
+
raise ValueError(
|
|
2174
|
+
f"on_startup hook for app '{slug}' must be callable, "
|
|
2175
|
+
f"got {type(on_startup).__name__}"
|
|
2176
|
+
)
|
|
2177
|
+
if on_shutdown is not None and not callable(on_shutdown):
|
|
2178
|
+
raise ValueError(
|
|
2179
|
+
f"on_shutdown hook for app '{slug}' must be callable, "
|
|
2180
|
+
f"got {type(on_shutdown).__name__}"
|
|
2181
|
+
)
|
|
2182
|
+
|
|
2183
|
+
# State for parent app
|
|
2184
|
+
# Build initial mounted_apps metadata synchronously so get_mounted_apps() works
|
|
2185
|
+
# immediately after create_multi_app() returns (before lifespan runs)
|
|
2186
|
+
mounted_apps: list[dict[str, Any]] = [
|
|
2187
|
+
{
|
|
2188
|
+
"slug": app_config["slug"],
|
|
2189
|
+
"path_prefix": app_config["path_prefix"],
|
|
2190
|
+
"status": "pending", # Will be updated in lifespan to "mounted" or "failed"
|
|
2191
|
+
"manifest_path": str(app_config["manifest"]),
|
|
2192
|
+
}
|
|
2193
|
+
for app_config in apps
|
|
2194
|
+
]
|
|
2195
|
+
shared_user_pool_initialized = False
|
|
2196
|
+
|
|
2197
|
+
def _find_mounted_app_entry(slug: str) -> dict[str, Any] | None:
|
|
2198
|
+
"""Find mounted app entry by slug."""
|
|
2199
|
+
for entry in mounted_apps:
|
|
2200
|
+
if entry.get("slug") == slug:
|
|
2201
|
+
return entry
|
|
2202
|
+
return None
|
|
2203
|
+
|
|
2204
|
+
async def _merge_cors_config_to_parent(
|
|
2205
|
+
parent_app: "FastAPI",
|
|
2206
|
+
child_app: "FastAPI",
|
|
2207
|
+
child_manifest: dict[str, Any],
|
|
2208
|
+
slug: str,
|
|
2209
|
+
) -> None:
|
|
2210
|
+
"""Merge CORS config from child app to parent app."""
|
|
2211
|
+
child_cors = None
|
|
2212
|
+
if hasattr(child_app.state, "cors_config"):
|
|
2213
|
+
child_cors = child_app.state.cors_config
|
|
2214
|
+
else:
|
|
2215
|
+
# CORS config might not be set yet (lifespan runs asynchronously)
|
|
2216
|
+
# Get it from manifest directly
|
|
2217
|
+
cors_config_from_manifest = child_manifest.get("cors", {})
|
|
2218
|
+
if cors_config_from_manifest:
|
|
2219
|
+
from ..auth.config_helpers import (
|
|
2220
|
+
CORS_DEFAULTS,
|
|
2221
|
+
merge_config_with_defaults,
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
child_cors = merge_config_with_defaults(
|
|
2225
|
+
cors_config_from_manifest, CORS_DEFAULTS
|
|
2226
|
+
)
|
|
2227
|
+
# Also set it on child app state for future reference
|
|
2228
|
+
child_app.state.cors_config = child_cors
|
|
2229
|
+
|
|
2230
|
+
if child_cors:
|
|
2231
|
+
if hasattr(parent_app.state, "cors_config"):
|
|
2232
|
+
# Merge child CORS into parent (child takes precedence for its routes)
|
|
2233
|
+
parent_cors = parent_app.state.cors_config
|
|
2234
|
+
# Merge allow_origins lists
|
|
2235
|
+
child_origins = child_cors.get("allow_origins", [])
|
|
2236
|
+
parent_origins = parent_cors.get("allow_origins", [])
|
|
2237
|
+
|
|
2238
|
+
# CRITICAL: Handle wildcard origins correctly
|
|
2239
|
+
# If any child or parent has wildcard, merged config gets wildcard
|
|
2240
|
+
if "*" in child_origins:
|
|
2241
|
+
merged_origins = ["*"] # Wildcard takes precedence
|
|
2242
|
+
elif "*" in parent_origins:
|
|
2243
|
+
merged_origins = ["*"] # Keep wildcard if already set
|
|
2244
|
+
else:
|
|
2245
|
+
# Merge unique origins
|
|
2246
|
+
merged_origins = list(set(parent_origins + child_origins))
|
|
2247
|
+
if not merged_origins:
|
|
2248
|
+
merged_origins = ["*"] # Default to wildcard if empty
|
|
2249
|
+
|
|
2250
|
+
# CRITICAL: If ANY child app requires credentials, parent must allow them
|
|
2251
|
+
# This is essential for SSO cookie-based authentication
|
|
2252
|
+
child_requires_credentials = child_cors.get("allow_credentials", False)
|
|
2253
|
+
parent_allows_credentials = parent_cors.get("allow_credentials", False)
|
|
2254
|
+
merged_allow_credentials = (
|
|
2255
|
+
child_requires_credentials or parent_allows_credentials
|
|
2256
|
+
)
|
|
2257
|
+
|
|
2258
|
+
parent_app.state.cors_config = {
|
|
2259
|
+
**parent_cors,
|
|
2260
|
+
**child_cors,
|
|
2261
|
+
"allow_origins": merged_origins,
|
|
2262
|
+
# If ANY child requires credentials, parent gets True (for SSO)
|
|
2263
|
+
"allow_credentials": merged_allow_credentials,
|
|
2264
|
+
}
|
|
2265
|
+
else:
|
|
2266
|
+
# Parent has no CORS config, use child's
|
|
2267
|
+
parent_app.state.cors_config = child_cors
|
|
2268
|
+
logger.info(
|
|
2269
|
+
f"✅ Merged CORS config from '{slug}': "
|
|
2270
|
+
f"origins={parent_app.state.cors_config.get('allow_origins')}, "
|
|
2271
|
+
f"credentials={parent_app.state.cors_config.get('allow_credentials')}"
|
|
2272
|
+
)
|
|
2273
|
+
|
|
2274
|
+
async def _register_websocket_routes(
|
|
2275
|
+
parent_app: "FastAPI",
|
|
2276
|
+
child_manifest: dict[str, Any],
|
|
2277
|
+
slug: str,
|
|
2278
|
+
path_prefix: str,
|
|
2279
|
+
) -> None:
|
|
2280
|
+
"""Register WebSocket routes on parent app for a child app."""
|
|
2281
|
+
websockets_config = child_manifest.get("websockets")
|
|
2282
|
+
if not websockets_config:
|
|
2283
|
+
logger.debug(f"No WebSocket configuration found for app '{slug}'")
|
|
2284
|
+
return
|
|
2285
|
+
|
|
2286
|
+
try:
|
|
2287
|
+
from fastapi import APIRouter
|
|
2288
|
+
|
|
2289
|
+
from ..routing.websockets import create_websocket_endpoint
|
|
2290
|
+
|
|
2291
|
+
registered_count = 0
|
|
2292
|
+
failed_count = 0
|
|
2293
|
+
|
|
2294
|
+
for endpoint_name, endpoint_config in websockets_config.items():
|
|
2295
|
+
ws_path = endpoint_config.get("path", f"/{endpoint_name}")
|
|
2296
|
+
# Combine mount prefix with WebSocket path
|
|
2297
|
+
full_ws_path = f"{path_prefix.rstrip('/')}{ws_path}"
|
|
2298
|
+
|
|
2299
|
+
# Handle auth configuration
|
|
2300
|
+
auth_config = endpoint_config.get("auth", {})
|
|
2301
|
+
if isinstance(auth_config, dict) and "required" in auth_config:
|
|
2302
|
+
require_auth = auth_config.get("required", True)
|
|
2303
|
+
elif "require_auth" in endpoint_config:
|
|
2304
|
+
require_auth = endpoint_config.get("require_auth", True)
|
|
2305
|
+
else:
|
|
2306
|
+
# Use app's auth_policy if available
|
|
2307
|
+
if "auth_policy" in child_manifest:
|
|
2308
|
+
require_auth = child_manifest["auth_policy"].get("required", True)
|
|
2309
|
+
else:
|
|
2310
|
+
require_auth = True
|
|
2311
|
+
|
|
2312
|
+
ping_interval = endpoint_config.get("ping_interval", 30)
|
|
2313
|
+
|
|
2314
|
+
try:
|
|
2315
|
+
# Create WebSocket handler
|
|
2316
|
+
handler = create_websocket_endpoint(
|
|
2317
|
+
app_slug=slug,
|
|
2318
|
+
path=ws_path,
|
|
2319
|
+
endpoint_name=endpoint_name,
|
|
2320
|
+
handler=None,
|
|
2321
|
+
require_auth=require_auth,
|
|
2322
|
+
ping_interval=ping_interval,
|
|
2323
|
+
)
|
|
2324
|
+
|
|
2325
|
+
# Register on parent app with full path
|
|
2326
|
+
ws_router = APIRouter()
|
|
2327
|
+
ws_router.websocket(full_ws_path)(handler)
|
|
2328
|
+
parent_app.include_router(ws_router)
|
|
2329
|
+
|
|
2330
|
+
logger.info(
|
|
2331
|
+
f"✅ Registered WebSocket route '{full_ws_path}' "
|
|
2332
|
+
f"for mounted app '{slug}' (mounted at '{path_prefix}', "
|
|
2333
|
+
f"auth: {require_auth}, ping: {ping_interval}s)"
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
# Verify route was actually registered
|
|
2337
|
+
registered_routes = [
|
|
2338
|
+
r
|
|
2339
|
+
for r in parent_app.routes
|
|
2340
|
+
if hasattr(r, "path") and full_ws_path in str(getattr(r, "path", ""))
|
|
2341
|
+
]
|
|
2342
|
+
if registered_routes:
|
|
2343
|
+
registered_count += 1
|
|
2344
|
+
logger.debug(
|
|
2345
|
+
f"✅ Verified WebSocket route '{full_ws_path}' "
|
|
2346
|
+
f"registered for '{slug}'"
|
|
2347
|
+
)
|
|
2348
|
+
else:
|
|
2349
|
+
failed_count += 1
|
|
2350
|
+
logger.warning(
|
|
2351
|
+
f"⚠️ WebSocket route '{full_ws_path}' not found after registration "
|
|
2352
|
+
f"for '{slug}' - route may not be accessible"
|
|
2353
|
+
)
|
|
2354
|
+
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
2355
|
+
failed_count += 1
|
|
2356
|
+
logger.error(
|
|
2357
|
+
f"Failed to register WebSocket route '{full_ws_path}' "
|
|
2358
|
+
f"for mounted app '{slug}': {e}",
|
|
2359
|
+
exc_info=True,
|
|
2360
|
+
)
|
|
2361
|
+
|
|
2362
|
+
# Summary logging
|
|
2363
|
+
total_routes = len(websockets_config)
|
|
2364
|
+
if registered_count > 0:
|
|
2365
|
+
logger.info(
|
|
2366
|
+
f"✅ WebSocket registration summary for '{slug}': "
|
|
2367
|
+
f"{registered_count}/{total_routes} routes registered successfully"
|
|
2368
|
+
)
|
|
2369
|
+
if failed_count > 0:
|
|
2370
|
+
logger.warning(
|
|
2371
|
+
f"⚠️ WebSocket registration issues for '{slug}': "
|
|
2372
|
+
f"{failed_count}/{total_routes} routes failed to register"
|
|
2373
|
+
)
|
|
2374
|
+
except ImportError:
|
|
2375
|
+
logger.warning(
|
|
2376
|
+
f"WebSocket support not available - skipping WebSocket routes "
|
|
2377
|
+
f"for mounted app '{slug}'"
|
|
2378
|
+
)
|
|
2379
|
+
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
2380
|
+
logger.error(
|
|
2381
|
+
f"Failed to register WebSocket routes for mounted app '{slug}': {e}",
|
|
2382
|
+
exc_info=True,
|
|
2383
|
+
)
|
|
2384
|
+
|
|
2385
|
+
@asynccontextmanager
|
|
2386
|
+
async def lifespan(app: FastAPI):
|
|
2387
|
+
"""Lifespan context manager for parent app."""
|
|
2388
|
+
nonlocal mounted_apps, shared_user_pool_initialized
|
|
2389
|
+
|
|
2390
|
+
# Initialize engine
|
|
2391
|
+
await engine.initialize()
|
|
2392
|
+
|
|
2393
|
+
# Initialize shared user pool once if any app uses shared auth
|
|
2394
|
+
if has_shared_auth:
|
|
2395
|
+
logger.info("Initializing shared user pool for multi-app deployment")
|
|
2396
|
+
# Find first app with shared auth to get manifest for initialization
|
|
2397
|
+
for app_config in apps:
|
|
2398
|
+
try:
|
|
2399
|
+
manifest_path = app_config["manifest"]
|
|
2400
|
+
with open(manifest_path) as f:
|
|
2401
|
+
app_manifest_pre = json.load(f)
|
|
2402
|
+
auth_config = app_manifest_pre.get("auth", {})
|
|
2403
|
+
if auth_config.get("mode") == "shared":
|
|
2404
|
+
await engine._initialize_shared_user_pool(app, app_manifest_pre)
|
|
2405
|
+
shared_user_pool_initialized = True
|
|
2406
|
+
logger.info("Shared user pool initialized for multi-app deployment")
|
|
2407
|
+
break
|
|
2408
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
2409
|
+
app_slug = app_config.get("slug", "unknown")
|
|
2410
|
+
logger.warning(
|
|
2411
|
+
f"Could not initialize shared user pool from app '{app_slug}': {e}"
|
|
2412
|
+
)
|
|
2413
|
+
|
|
2414
|
+
# Mount each child app
|
|
2415
|
+
for app_config in apps:
|
|
2416
|
+
slug = app_config["slug"]
|
|
2417
|
+
manifest_path = app_config["manifest"]
|
|
2418
|
+
path_prefix = app_config["path_prefix"]
|
|
2419
|
+
on_startup = app_config.get("on_startup")
|
|
2420
|
+
on_shutdown = app_config.get("on_shutdown")
|
|
2421
|
+
|
|
2422
|
+
try:
|
|
2423
|
+
# Load manifest for context helpers
|
|
2424
|
+
try:
|
|
2425
|
+
with open(manifest_path) as f:
|
|
2426
|
+
app_manifest_data = json.load(f)
|
|
2427
|
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
2428
|
+
raise ValueError(
|
|
2429
|
+
f"Failed to load manifest for app '{slug}' at {manifest_path}: {e}"
|
|
2430
|
+
) from e
|
|
2431
|
+
|
|
2432
|
+
# Log app configuration
|
|
2433
|
+
auth_config = app_manifest_data.get("auth", {})
|
|
2434
|
+
auth_mode = auth_config.get("mode", "app")
|
|
2435
|
+
public_routes = auth_config.get("public_routes", [])
|
|
2436
|
+
logger.info(
|
|
2437
|
+
f"Mounting app '{slug}' at '{path_prefix}': "
|
|
2438
|
+
f"auth_mode={auth_mode}, "
|
|
2439
|
+
f"public_routes={len(public_routes)} routes"
|
|
2440
|
+
)
|
|
2441
|
+
if public_routes:
|
|
2442
|
+
logger.debug(f" Public routes for '{slug}': {public_routes}")
|
|
2443
|
+
else:
|
|
2444
|
+
logger.warning(
|
|
2445
|
+
f" App '{slug}' has no public routes configured. "
|
|
2446
|
+
"All routes will require authentication."
|
|
2447
|
+
)
|
|
2448
|
+
|
|
2449
|
+
# Create child app as sub-app (shares engine and lifecycle)
|
|
2450
|
+
child_app = engine.create_app(
|
|
2451
|
+
slug=slug,
|
|
2452
|
+
manifest=manifest_path,
|
|
2453
|
+
is_sub_app=True, # Important: marks as sub-app
|
|
2454
|
+
on_startup=on_startup,
|
|
2455
|
+
on_shutdown=on_shutdown,
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
# CRITICAL: Set engine state BEFORE importing routes
|
|
2459
|
+
# Routes may use dependencies that need request.app.state.engine
|
|
2460
|
+
# This must be set before route decorators execute
|
|
2461
|
+
child_app.state.engine = engine
|
|
2462
|
+
child_app.state.app_slug = slug
|
|
2463
|
+
|
|
2464
|
+
# Automatically import routes from app module
|
|
2465
|
+
# This discovers and imports route modules (web.py, routes.py, etc.)
|
|
2466
|
+
# so that route decorators are executed and routes are registered
|
|
2467
|
+
try:
|
|
2468
|
+
self._import_app_routes(child_app, manifest_path, slug)
|
|
2469
|
+
except (
|
|
2470
|
+
ValueError,
|
|
2471
|
+
TypeError,
|
|
2472
|
+
AttributeError,
|
|
2473
|
+
RuntimeError,
|
|
2474
|
+
ImportError,
|
|
2475
|
+
SyntaxError,
|
|
2476
|
+
OSError,
|
|
2477
|
+
) as e:
|
|
2478
|
+
logger.warning(
|
|
2479
|
+
f"Failed to auto-import routes for app '{slug}': {e}. "
|
|
2480
|
+
"Routes may need to be imported manually.",
|
|
2481
|
+
exc_info=True,
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
# Share user_pool with child app if shared auth is enabled
|
|
2485
|
+
if shared_user_pool_initialized and hasattr(app.state, "user_pool"):
|
|
2486
|
+
child_app.state.user_pool = app.state.user_pool
|
|
2487
|
+
# Also share audit_log if available
|
|
2488
|
+
if hasattr(app.state, "audit_log"):
|
|
2489
|
+
child_app.state.audit_log = app.state.audit_log
|
|
2490
|
+
logger.debug(f"Shared user_pool with child app '{slug}'")
|
|
2491
|
+
|
|
2492
|
+
# Add middleware for app context helpers
|
|
2493
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2494
|
+
from starlette.requests import Request
|
|
2495
|
+
|
|
2496
|
+
# Get auth_hub_url from manifest or env
|
|
2497
|
+
auth_hub_url = None
|
|
2498
|
+
if auth_config.get("mode") == "shared":
|
|
2499
|
+
auth_hub_url = auth_config.get("auth_hub_url")
|
|
2500
|
+
if not auth_hub_url:
|
|
2501
|
+
auth_hub_url = os.getenv("AUTH_HUB_URL", "/auth-hub")
|
|
2502
|
+
|
|
2503
|
+
# Store parent app reference and current app info for middleware
|
|
2504
|
+
# Note: engine and app_slug are already set above (before route import)
|
|
2505
|
+
child_app.state.parent_app = app
|
|
2506
|
+
child_app.state.app_base_path = path_prefix
|
|
2507
|
+
child_app.state.app_auth_hub_url = auth_hub_url
|
|
2508
|
+
child_app.state.app_manifest = app_manifest_data
|
|
2509
|
+
|
|
2510
|
+
# Create middleware factory to properly capture loop variables
|
|
2511
|
+
def create_app_context_middleware(
|
|
2512
|
+
app_slug: str,
|
|
2513
|
+
app_path_prefix: str,
|
|
2514
|
+
app_auth_hub_url_val: str,
|
|
2515
|
+
app_manifest_data_val: dict[str, Any],
|
|
2516
|
+
) -> type[BaseHTTPMiddleware]:
|
|
2517
|
+
"""Create middleware class with captured variables."""
|
|
2518
|
+
|
|
2519
|
+
class _AppContextMiddleware(BaseHTTPMiddleware):
|
|
2520
|
+
"""Middleware that sets app context helpers on request.state."""
|
|
2521
|
+
|
|
2522
|
+
async def dispatch(self, request: Request, call_next):
|
|
2523
|
+
# Get parent app from child app state
|
|
2524
|
+
parent_app = getattr(request.app.state, "parent_app", None)
|
|
2525
|
+
|
|
2526
|
+
# Set app context helpers
|
|
2527
|
+
request.state.app_base_path = getattr(
|
|
2528
|
+
request.app.state,
|
|
2529
|
+
"app_base_path",
|
|
2530
|
+
app_path_prefix,
|
|
2531
|
+
)
|
|
2532
|
+
request.state.auth_hub_url = getattr(
|
|
2533
|
+
request.app.state,
|
|
2534
|
+
"app_auth_hub_url",
|
|
2535
|
+
app_auth_hub_url_val,
|
|
2536
|
+
)
|
|
2537
|
+
request.state.app_slug = getattr(
|
|
2538
|
+
request.app.state, "app_slug", app_slug
|
|
2539
|
+
)
|
|
2540
|
+
request.state.engine = engine
|
|
2541
|
+
request.state.manifest = getattr(
|
|
2542
|
+
request.app.state,
|
|
2543
|
+
"app_manifest",
|
|
2544
|
+
app_manifest_data_val,
|
|
2545
|
+
)
|
|
2546
|
+
|
|
2547
|
+
# Get mounted apps from parent app state
|
|
2548
|
+
if parent_app and hasattr(parent_app.state, "mounted_apps"):
|
|
2549
|
+
mounted_apps_list = parent_app.state.mounted_apps
|
|
2550
|
+
request.state.mounted_apps = {
|
|
2551
|
+
ma["slug"]: {
|
|
2552
|
+
"slug": ma["slug"],
|
|
2553
|
+
"path_prefix": ma.get("path_prefix"),
|
|
2554
|
+
"status": ma.get("status", "unknown"),
|
|
2555
|
+
}
|
|
2556
|
+
for ma in mounted_apps_list
|
|
2557
|
+
}
|
|
2558
|
+
else:
|
|
2559
|
+
# Fallback: create minimal dict with current app
|
|
2560
|
+
request.state.mounted_apps = {
|
|
2561
|
+
app_slug: {
|
|
2562
|
+
"slug": app_slug,
|
|
2563
|
+
"path_prefix": app_path_prefix,
|
|
2564
|
+
"status": "mounted",
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
response = await call_next(request)
|
|
2569
|
+
return response
|
|
2570
|
+
|
|
2571
|
+
return _AppContextMiddleware
|
|
2572
|
+
|
|
2573
|
+
middleware_class = create_app_context_middleware(
|
|
2574
|
+
slug, path_prefix, auth_hub_url, app_manifest_data
|
|
2575
|
+
)
|
|
2576
|
+
child_app.add_middleware(middleware_class)
|
|
2577
|
+
logger.debug(f"Added AppContextMiddleware to child app '{slug}'")
|
|
2578
|
+
|
|
2579
|
+
# Mount child app at path prefix
|
|
2580
|
+
app.mount(path_prefix, child_app)
|
|
2581
|
+
|
|
2582
|
+
# CRITICAL FIX: Merge CORS config from child app to parent app
|
|
2583
|
+
await _merge_cors_config_to_parent(app, child_app, app_manifest_data, slug)
|
|
2584
|
+
|
|
2585
|
+
# CRITICAL FIX: Register WebSocket routes on parent app with full path
|
|
2586
|
+
await _register_websocket_routes(app, app_manifest_data, slug, path_prefix)
|
|
2587
|
+
|
|
2588
|
+
# Update existing entry instead of appending
|
|
2589
|
+
entry = _find_mounted_app_entry(slug)
|
|
2590
|
+
if entry:
|
|
2591
|
+
entry.update(
|
|
2592
|
+
{
|
|
2593
|
+
"status": "mounted",
|
|
2594
|
+
"manifest": app_manifest_data,
|
|
2595
|
+
}
|
|
2596
|
+
)
|
|
2597
|
+
else:
|
|
2598
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2599
|
+
mounted_apps.append(
|
|
2600
|
+
{
|
|
2601
|
+
"slug": slug,
|
|
2602
|
+
"path_prefix": path_prefix,
|
|
2603
|
+
"status": "mounted",
|
|
2604
|
+
"manifest": app_manifest_data,
|
|
2605
|
+
}
|
|
2606
|
+
)
|
|
2607
|
+
logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
|
|
2608
|
+
|
|
2609
|
+
except FileNotFoundError as e:
|
|
2610
|
+
error_msg = (
|
|
2611
|
+
f"Failed to mount app '{slug}' at {path_prefix}: "
|
|
2612
|
+
f"manifest.json not found at {manifest_path}"
|
|
2613
|
+
)
|
|
2614
|
+
logger.error(error_msg, exc_info=True)
|
|
2615
|
+
# Update existing entry instead of appending
|
|
2616
|
+
entry = _find_mounted_app_entry(slug)
|
|
2617
|
+
if entry:
|
|
2618
|
+
entry.update(
|
|
2619
|
+
{
|
|
2620
|
+
"status": "failed",
|
|
2621
|
+
"error": error_msg,
|
|
2622
|
+
}
|
|
2623
|
+
)
|
|
2624
|
+
else:
|
|
2625
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2626
|
+
mounted_apps.append(
|
|
2627
|
+
{
|
|
2628
|
+
"slug": slug,
|
|
2629
|
+
"path_prefix": path_prefix,
|
|
2630
|
+
"status": "failed",
|
|
2631
|
+
"error": error_msg,
|
|
2632
|
+
"manifest_path": str(manifest_path),
|
|
2633
|
+
}
|
|
2634
|
+
)
|
|
2635
|
+
if strict:
|
|
2636
|
+
raise ValueError(error_msg) from e
|
|
2637
|
+
continue
|
|
2638
|
+
except json.JSONDecodeError as e:
|
|
2639
|
+
error_msg = (
|
|
2640
|
+
f"Failed to mount app '{slug}' at {path_prefix}: "
|
|
2641
|
+
f"Invalid JSON in manifest.json at {manifest_path}: {e}"
|
|
2642
|
+
)
|
|
2643
|
+
logger.error(error_msg, exc_info=True)
|
|
2644
|
+
# Update existing entry instead of appending
|
|
2645
|
+
entry = _find_mounted_app_entry(slug)
|
|
2646
|
+
if entry:
|
|
2647
|
+
entry.update(
|
|
2648
|
+
{
|
|
2649
|
+
"status": "failed",
|
|
2650
|
+
"error": error_msg,
|
|
2651
|
+
}
|
|
2652
|
+
)
|
|
2653
|
+
else:
|
|
2654
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2655
|
+
mounted_apps.append(
|
|
2656
|
+
{
|
|
2657
|
+
"slug": slug,
|
|
2658
|
+
"path_prefix": path_prefix,
|
|
2659
|
+
"status": "failed",
|
|
2660
|
+
"error": error_msg,
|
|
2661
|
+
"manifest_path": str(manifest_path),
|
|
2662
|
+
}
|
|
2663
|
+
)
|
|
2664
|
+
if strict:
|
|
2665
|
+
raise ValueError(error_msg) from e
|
|
2666
|
+
continue
|
|
2667
|
+
except ValueError as e:
|
|
2668
|
+
error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
|
|
2669
|
+
logger.error(error_msg, exc_info=True)
|
|
2670
|
+
# Update existing entry instead of appending
|
|
2671
|
+
entry = _find_mounted_app_entry(slug)
|
|
2672
|
+
if entry:
|
|
2673
|
+
entry.update(
|
|
2674
|
+
{
|
|
2675
|
+
"status": "failed",
|
|
2676
|
+
"error": error_msg,
|
|
2677
|
+
}
|
|
2678
|
+
)
|
|
2679
|
+
else:
|
|
2680
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2681
|
+
mounted_apps.append(
|
|
2682
|
+
{
|
|
2683
|
+
"slug": slug,
|
|
2684
|
+
"path_prefix": path_prefix,
|
|
2685
|
+
"status": "failed",
|
|
2686
|
+
"error": error_msg,
|
|
2687
|
+
"manifest_path": str(manifest_path),
|
|
2688
|
+
}
|
|
2689
|
+
)
|
|
2690
|
+
if strict:
|
|
2691
|
+
raise ValueError(error_msg) from e
|
|
2692
|
+
continue
|
|
2693
|
+
except (KeyError, RuntimeError) as e:
|
|
2694
|
+
error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
|
|
2695
|
+
logger.error(error_msg, exc_info=True)
|
|
2696
|
+
# Update existing entry instead of appending
|
|
2697
|
+
entry = _find_mounted_app_entry(slug)
|
|
2698
|
+
if entry:
|
|
2699
|
+
entry.update(
|
|
2700
|
+
{
|
|
2701
|
+
"status": "failed",
|
|
2702
|
+
"error": error_msg,
|
|
2703
|
+
}
|
|
2704
|
+
)
|
|
2705
|
+
else:
|
|
2706
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2707
|
+
mounted_apps.append(
|
|
2708
|
+
{
|
|
2709
|
+
"slug": slug,
|
|
2710
|
+
"path_prefix": path_prefix,
|
|
2711
|
+
"status": "failed",
|
|
2712
|
+
"error": error_msg,
|
|
2713
|
+
"manifest_path": str(manifest_path),
|
|
2714
|
+
}
|
|
2715
|
+
)
|
|
2716
|
+
if strict:
|
|
2717
|
+
raise RuntimeError(error_msg) from e
|
|
2718
|
+
continue
|
|
2719
|
+
except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
|
|
2720
|
+
error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
|
|
2721
|
+
logger.error(error_msg, exc_info=True)
|
|
2722
|
+
# Update existing entry instead of appending
|
|
2723
|
+
entry = _find_mounted_app_entry(slug)
|
|
2724
|
+
if entry:
|
|
2725
|
+
entry.update(
|
|
2726
|
+
{
|
|
2727
|
+
"status": "failed",
|
|
2728
|
+
"error": error_msg,
|
|
2729
|
+
}
|
|
2730
|
+
)
|
|
2731
|
+
else:
|
|
2732
|
+
# Fallback: append if entry not found (shouldn't happen)
|
|
2733
|
+
mounted_apps.append(
|
|
2734
|
+
{
|
|
2735
|
+
"slug": slug,
|
|
2736
|
+
"path_prefix": path_prefix,
|
|
2737
|
+
"status": "failed",
|
|
2738
|
+
"error": error_msg,
|
|
2739
|
+
"manifest_path": str(manifest_path),
|
|
2740
|
+
}
|
|
2741
|
+
)
|
|
2742
|
+
if strict:
|
|
2743
|
+
raise RuntimeError(error_msg) from e
|
|
2744
|
+
continue
|
|
2745
|
+
|
|
2746
|
+
# Update app.state.mounted_apps with final status (entries already updated in place)
|
|
2747
|
+
# This ensures the state reflects the final mounted_apps list
|
|
2748
|
+
app.state.mounted_apps = mounted_apps
|
|
2749
|
+
|
|
2750
|
+
# VERIFICATION: Log final configuration state
|
|
2751
|
+
logger.info("=" * 60)
|
|
2752
|
+
logger.info("MDB-Engine Multi-App Configuration Verification")
|
|
2753
|
+
logger.info("=" * 60)
|
|
2754
|
+
|
|
2755
|
+
# Verify CORS config
|
|
2756
|
+
cors_config = getattr(app.state, "cors_config", None)
|
|
2757
|
+
if cors_config:
|
|
2758
|
+
logger.info(
|
|
2759
|
+
f"✅ CORS Config: enabled={cors_config.get('enabled')}, "
|
|
2760
|
+
f"origins={cors_config.get('allow_origins')}, "
|
|
2761
|
+
f"credentials={cors_config.get('allow_credentials')}"
|
|
2762
|
+
)
|
|
2763
|
+
else:
|
|
2764
|
+
logger.warning("⚠️ No CORS config found on parent app")
|
|
2765
|
+
|
|
2766
|
+
# Verify WebSocket routes
|
|
2767
|
+
ws_routes = [
|
|
2768
|
+
r for r in app.routes if hasattr(r, "path") and "/ws" in str(getattr(r, "path", ""))
|
|
2769
|
+
]
|
|
2770
|
+
if ws_routes:
|
|
2771
|
+
logger.info(f"✅ Found {len(ws_routes)} WebSocket route(s):")
|
|
2772
|
+
for route in ws_routes:
|
|
2773
|
+
route_path = getattr(route, "path", "unknown")
|
|
2774
|
+
logger.info(f" 🔌 {route_path}")
|
|
2775
|
+
else:
|
|
2776
|
+
logger.warning(
|
|
2777
|
+
"⚠️ No WebSocket routes found - check manifest.json websockets config"
|
|
2778
|
+
)
|
|
2779
|
+
|
|
2780
|
+
logger.info("=" * 60)
|
|
2781
|
+
|
|
2782
|
+
yield
|
|
2783
|
+
|
|
2784
|
+
# Shutdown is handled by parent app
|
|
2785
|
+
await engine.shutdown()
|
|
2786
|
+
|
|
2787
|
+
# Create parent FastAPI app
|
|
2788
|
+
parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
|
|
2789
|
+
|
|
2790
|
+
# Set mounted_apps immediately so get_mounted_apps() works before lifespan runs
|
|
2791
|
+
parent_app.state.mounted_apps = mounted_apps
|
|
2792
|
+
parent_app.state.is_multi_app = True
|
|
2793
|
+
parent_app.state.engine = engine
|
|
2794
|
+
|
|
2795
|
+
# Set default CORS config on parent app for WebSocket origin validation
|
|
2796
|
+
# This ensures CSRF middleware can validate WebSocket origins even if child apps
|
|
2797
|
+
# don't configure CORS
|
|
2798
|
+
# NOTE: allow_credentials defaults to False here, but will be set to True
|
|
2799
|
+
# during merge if any child app requires it (essential for SSO cookie-based auth)
|
|
2800
|
+
from ..auth.config_helpers import CORS_DEFAULTS
|
|
2801
|
+
|
|
2802
|
+
parent_app.state.cors_config = CORS_DEFAULTS.copy()
|
|
2803
|
+
parent_app.state.cors_config["enabled"] = True
|
|
2804
|
+
parent_app.state.cors_config["allow_origins"] = ["*"] # Default to allow all for WebSocket
|
|
2805
|
+
# Keep allow_credentials as False initially - will be merged from child apps
|
|
2806
|
+
logger.debug("Set default CORS config on parent app for WebSocket origin validation")
|
|
2807
|
+
|
|
2808
|
+
# Store app reference in engine for get_mounted_apps()
|
|
2809
|
+
engine._multi_app_instance = parent_app
|
|
2810
|
+
|
|
2811
|
+
# Add request scope middleware
|
|
2812
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2813
|
+
|
|
2814
|
+
from ..di import ScopeManager
|
|
2815
|
+
|
|
2816
|
+
class RequestScopeMiddleware(BaseHTTPMiddleware):
|
|
2817
|
+
"""Middleware that manages request-scoped DI instances."""
|
|
2818
|
+
|
|
2819
|
+
async def dispatch(self, request, call_next):
|
|
2820
|
+
ScopeManager.begin_request()
|
|
2821
|
+
try:
|
|
2822
|
+
response = await call_next(request)
|
|
2823
|
+
return response
|
|
2824
|
+
finally:
|
|
2825
|
+
ScopeManager.end_request()
|
|
2826
|
+
|
|
2827
|
+
parent_app.add_middleware(RequestScopeMiddleware)
|
|
2828
|
+
logger.debug("RequestScopeMiddleware added for parent app")
|
|
2829
|
+
|
|
2830
|
+
# CRITICAL: Add CSRF middleware to parent app if any child app uses shared auth
|
|
2831
|
+
# WebSocket routes are registered on parent app, so parent app middleware runs first
|
|
2832
|
+
# CSRF middleware on parent app validates WebSocket origin using parent app's CORS config
|
|
2833
|
+
if has_shared_auth:
|
|
2834
|
+
from ..auth.csrf import create_csrf_middleware
|
|
2835
|
+
|
|
2836
|
+
# Create CSRF middleware with default config (will use parent app's CORS config)
|
|
2837
|
+
# Exempt routes that don't need CSRF (health checks, public routes from child apps)
|
|
2838
|
+
# all_public_routes includes base routes + child app public routes with path prefixes
|
|
2839
|
+
parent_csrf_config = {
|
|
2840
|
+
"csrf_protection": True,
|
|
2841
|
+
"public_routes": all_public_routes,
|
|
2842
|
+
}
|
|
2843
|
+
csrf_middleware = create_csrf_middleware(parent_csrf_config)
|
|
2844
|
+
parent_app.add_middleware(csrf_middleware)
|
|
2845
|
+
logger.info("CSRFMiddleware added to parent app for WebSocket origin validation")
|
|
2846
|
+
|
|
2847
|
+
# Add shared CORS middleware if configured
|
|
2848
|
+
# NOTE: We create a dynamic CORS middleware that reads from app.state.cors_config
|
|
2849
|
+
# This allows the config to be updated after child apps are mounted and merged
|
|
2850
|
+
try:
|
|
2851
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2852
|
+
from starlette.requests import Request
|
|
2853
|
+
from starlette.responses import Response
|
|
2854
|
+
|
|
2855
|
+
class DynamicCORSMiddleware(BaseHTTPMiddleware):
|
|
2856
|
+
"""
|
|
2857
|
+
Dynamic CORS middleware that reads config from app.state.cors_config.
|
|
2858
|
+
|
|
2859
|
+
This allows CORS config to be updated after child apps are mounted
|
|
2860
|
+
and their configs are merged, which is essential for SSO multi-app
|
|
2861
|
+
setups where allow_credentials must be True for cookie-based auth.
|
|
2862
|
+
"""
|
|
2863
|
+
|
|
2864
|
+
async def dispatch(self, request: Request, call_next):
|
|
2865
|
+
# Read CORS config from app.state (may have been merged from child apps)
|
|
2866
|
+
cors_config = getattr(request.app.state, "cors_config", {})
|
|
2867
|
+
|
|
2868
|
+
if not cors_config.get("enabled", False):
|
|
2869
|
+
# CORS not enabled, pass through
|
|
2870
|
+
return await call_next(request)
|
|
2871
|
+
|
|
2872
|
+
# Handle preflight OPTIONS request
|
|
2873
|
+
if request.method == "OPTIONS":
|
|
2874
|
+
origin = request.headers.get("origin")
|
|
2875
|
+
allowed_origins = cors_config.get("allow_origins", ["*"])
|
|
2876
|
+
allow_credentials = cors_config.get("allow_credentials", False)
|
|
2877
|
+
|
|
2878
|
+
# Check if origin is allowed
|
|
2879
|
+
origin_allowed = False
|
|
2880
|
+
if "*" in allowed_origins:
|
|
2881
|
+
origin_allowed = True
|
|
2882
|
+
elif origin in allowed_origins:
|
|
2883
|
+
origin_allowed = True
|
|
2884
|
+
|
|
2885
|
+
if origin_allowed:
|
|
2886
|
+
headers = {
|
|
2887
|
+
"Access-Control-Allow-Methods": ", ".join(
|
|
2888
|
+
cors_config.get("allow_methods", ["*"])
|
|
2889
|
+
),
|
|
2890
|
+
"Access-Control-Allow-Headers": ", ".join(
|
|
2891
|
+
cors_config.get("allow_headers", ["*"])
|
|
2892
|
+
),
|
|
2893
|
+
"Access-Control-Max-Age": str(cors_config.get("max_age", 3600)),
|
|
2894
|
+
}
|
|
2895
|
+
if allow_credentials:
|
|
2896
|
+
headers["Access-Control-Allow-Credentials"] = "true"
|
|
2897
|
+
if origin:
|
|
2898
|
+
headers["Access-Control-Allow-Origin"] = origin
|
|
2899
|
+
|
|
2900
|
+
expose_headers = cors_config.get("expose_headers", [])
|
|
2901
|
+
if expose_headers:
|
|
2902
|
+
headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers)
|
|
2903
|
+
|
|
2904
|
+
return Response(status_code=200, headers=headers)
|
|
2905
|
+
else:
|
|
2906
|
+
return Response(status_code=403)
|
|
2907
|
+
|
|
2908
|
+
# Handle actual request
|
|
2909
|
+
response = await call_next(request)
|
|
2910
|
+
|
|
2911
|
+
# Add CORS headers to response
|
|
2912
|
+
origin = request.headers.get("origin")
|
|
2913
|
+
allowed_origins = cors_config.get("allow_origins", ["*"])
|
|
2914
|
+
allow_credentials = cors_config.get("allow_credentials", False)
|
|
2915
|
+
|
|
2916
|
+
# Check if origin is allowed
|
|
2917
|
+
origin_allowed = False
|
|
2918
|
+
if "*" in allowed_origins:
|
|
2919
|
+
origin_allowed = True
|
|
2920
|
+
elif origin and origin in allowed_origins:
|
|
2921
|
+
origin_allowed = True
|
|
2922
|
+
|
|
2923
|
+
if origin_allowed:
|
|
2924
|
+
if origin:
|
|
2925
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
2926
|
+
if allow_credentials:
|
|
2927
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
2928
|
+
|
|
2929
|
+
expose_headers = cors_config.get("expose_headers", [])
|
|
2930
|
+
if expose_headers:
|
|
2931
|
+
response.headers["Access-Control-Expose-Headers"] = ", ".join(
|
|
2932
|
+
expose_headers
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2935
|
+
return response
|
|
2936
|
+
|
|
2937
|
+
parent_app.add_middleware(DynamicCORSMiddleware)
|
|
2938
|
+
logger.debug(
|
|
2939
|
+
"Dynamic CORS middleware added for parent app (reads from app.state.cors_config)"
|
|
2940
|
+
)
|
|
2941
|
+
except ImportError:
|
|
2942
|
+
logger.warning("CORS middleware not available")
|
|
2943
|
+
|
|
2944
|
+
# Add unified health check endpoint
|
|
2945
|
+
@parent_app.get("/health")
|
|
2946
|
+
async def health_check():
|
|
2947
|
+
"""Unified health check for all mounted apps."""
|
|
2948
|
+
import time
|
|
2949
|
+
|
|
2950
|
+
from ..observability import check_engine_health, check_mongodb_health
|
|
2951
|
+
|
|
2952
|
+
# Both are async functions
|
|
2953
|
+
start_time = time.time()
|
|
2954
|
+
engine_health = await check_engine_health(engine)
|
|
2955
|
+
mongo_health = await check_mongodb_health(engine.mongo_client)
|
|
2956
|
+
engine_response_time = int((time.time() - start_time) * 1000)
|
|
2957
|
+
|
|
2958
|
+
# Check each mounted app's status
|
|
2959
|
+
mounted_status = {}
|
|
2960
|
+
for mounted_app_info in mounted_apps:
|
|
2961
|
+
app_slug = mounted_app_info["slug"]
|
|
2962
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
2963
|
+
status = mounted_app_info["status"]
|
|
2964
|
+
|
|
2965
|
+
app_status = {
|
|
2966
|
+
"path_prefix": path_prefix,
|
|
2967
|
+
"status": status,
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
if "error" in mounted_app_info:
|
|
2971
|
+
app_status["error"] = mounted_app_info["error"]
|
|
2972
|
+
app_status["status"] = "unhealthy"
|
|
2973
|
+
elif status == "mounted":
|
|
2974
|
+
# App is mounted successfully
|
|
2975
|
+
app_status["status"] = "healthy"
|
|
2976
|
+
# Try to get response time by checking if app has routes
|
|
2977
|
+
try:
|
|
2978
|
+
# Find the mounted app and check its route count
|
|
2979
|
+
for route in parent_app.routes:
|
|
2980
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
2981
|
+
if hasattr(route, "app"):
|
|
2982
|
+
mounted_app = route.app
|
|
2983
|
+
route_count = len(mounted_app.routes)
|
|
2984
|
+
app_status["route_count"] = route_count
|
|
2985
|
+
break
|
|
2986
|
+
except (AttributeError, TypeError, KeyError):
|
|
2987
|
+
pass
|
|
2988
|
+
|
|
2989
|
+
mounted_status[app_slug] = app_status
|
|
2990
|
+
|
|
2991
|
+
# Determine overall status
|
|
2992
|
+
all_healthy = (
|
|
2993
|
+
engine_health.status.value == "healthy"
|
|
2994
|
+
and mongo_health.status.value == "healthy"
|
|
2995
|
+
and all(
|
|
2996
|
+
app_info.get("status") in ("healthy", "mounted")
|
|
2997
|
+
for app_info in mounted_status.values()
|
|
2998
|
+
)
|
|
2999
|
+
)
|
|
3000
|
+
|
|
3001
|
+
overall_status = "healthy" if all_healthy else "unhealthy"
|
|
3002
|
+
|
|
3003
|
+
return {
|
|
3004
|
+
"status": overall_status,
|
|
3005
|
+
"engine": {
|
|
3006
|
+
"status": engine_health.status.value,
|
|
3007
|
+
"message": engine_health.message,
|
|
3008
|
+
"response_time_ms": engine_response_time,
|
|
3009
|
+
},
|
|
3010
|
+
"mongodb": {
|
|
3011
|
+
"status": mongo_health.status.value,
|
|
3012
|
+
"message": mongo_health.message,
|
|
3013
|
+
},
|
|
3014
|
+
"apps": mounted_status,
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
# Add route introspection endpoint
|
|
3018
|
+
@parent_app.get("/_mdb/routes")
|
|
3019
|
+
async def list_routes():
|
|
3020
|
+
"""List all routes from all mounted apps."""
|
|
3021
|
+
routes_info = {
|
|
3022
|
+
"parent_app": {
|
|
3023
|
+
"routes": [],
|
|
3024
|
+
},
|
|
3025
|
+
"mounted_apps": {},
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
# Get parent app routes
|
|
3029
|
+
for route in parent_app.routes:
|
|
3030
|
+
route_info = {
|
|
3031
|
+
"path": getattr(route, "path", str(route)),
|
|
3032
|
+
"methods": list(getattr(route, "methods", set())),
|
|
3033
|
+
"name": getattr(route, "name", None),
|
|
3034
|
+
}
|
|
3035
|
+
routes_info["parent_app"]["routes"].append(route_info)
|
|
3036
|
+
|
|
3037
|
+
# Get routes from mounted apps
|
|
3038
|
+
for mounted_app_info in mounted_apps:
|
|
3039
|
+
app_slug = mounted_app_info["slug"]
|
|
3040
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
3041
|
+
status = mounted_app_info["status"]
|
|
3042
|
+
|
|
3043
|
+
if status != "mounted":
|
|
3044
|
+
routes_info["mounted_apps"][app_slug] = {
|
|
3045
|
+
"path_prefix": path_prefix,
|
|
3046
|
+
"status": status,
|
|
3047
|
+
"routes": [],
|
|
3048
|
+
"error": mounted_app_info.get("error"),
|
|
3049
|
+
}
|
|
3050
|
+
continue
|
|
3051
|
+
|
|
3052
|
+
# Find the mounted app
|
|
3053
|
+
app_routes = []
|
|
3054
|
+
for route in parent_app.routes:
|
|
3055
|
+
# Check if this route belongs to the mounted app
|
|
3056
|
+
# Mounted apps appear as Mount routes
|
|
3057
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
3058
|
+
# This is the mount point
|
|
3059
|
+
if hasattr(route, "app"):
|
|
3060
|
+
# Get routes from the mounted app
|
|
3061
|
+
mounted_app = route.app
|
|
3062
|
+
for child_route in mounted_app.routes:
|
|
3063
|
+
route_path = getattr(child_route, "path", str(child_route))
|
|
3064
|
+
# Prepend path prefix
|
|
3065
|
+
full_path = (
|
|
3066
|
+
f"{path_prefix}{route_path}"
|
|
3067
|
+
if route_path != "/"
|
|
3068
|
+
else path_prefix
|
|
3069
|
+
)
|
|
3070
|
+
|
|
3071
|
+
route_info = {
|
|
3072
|
+
"path": full_path,
|
|
3073
|
+
"relative_path": route_path,
|
|
3074
|
+
"methods": list(getattr(child_route, "methods", set())),
|
|
3075
|
+
"name": getattr(child_route, "name", None),
|
|
3076
|
+
}
|
|
3077
|
+
app_routes.append(route_info)
|
|
3078
|
+
|
|
3079
|
+
routes_info["mounted_apps"][app_slug] = {
|
|
3080
|
+
"path_prefix": path_prefix,
|
|
3081
|
+
"status": status,
|
|
3082
|
+
"routes": app_routes,
|
|
3083
|
+
"route_count": len(app_routes),
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
return routes_info
|
|
3087
|
+
|
|
3088
|
+
# Aggregate OpenAPI docs from all mounted apps
|
|
3089
|
+
def custom_openapi():
|
|
3090
|
+
"""Generate aggregated OpenAPI schema from all mounted apps."""
|
|
3091
|
+
from fastapi.openapi.utils import get_openapi
|
|
3092
|
+
|
|
3093
|
+
if parent_app.openapi_schema:
|
|
3094
|
+
return parent_app.openapi_schema
|
|
3095
|
+
|
|
3096
|
+
# Get base schema from parent app
|
|
3097
|
+
openapi_schema = get_openapi(
|
|
3098
|
+
title=title,
|
|
3099
|
+
version=fastapi_kwargs.get("version", "1.0.0"),
|
|
3100
|
+
description=fastapi_kwargs.get("description", ""),
|
|
3101
|
+
routes=parent_app.routes,
|
|
3102
|
+
)
|
|
3103
|
+
|
|
3104
|
+
# Aggregate schemas from mounted apps
|
|
3105
|
+
for mounted_app_info in mounted_apps:
|
|
3106
|
+
if mounted_app_info.get("status") != "mounted":
|
|
3107
|
+
continue
|
|
3108
|
+
|
|
3109
|
+
app_slug = mounted_app_info["slug"]
|
|
3110
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
3111
|
+
|
|
3112
|
+
# Find the mounted app
|
|
3113
|
+
for route in parent_app.routes:
|
|
3114
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
3115
|
+
if hasattr(route, "app"):
|
|
3116
|
+
mounted_app = route.app
|
|
3117
|
+
try:
|
|
3118
|
+
# Get OpenAPI schema from mounted app
|
|
3119
|
+
child_schema = get_openapi(
|
|
3120
|
+
title=getattr(mounted_app, "title", app_slug),
|
|
3121
|
+
version=getattr(mounted_app, "version", "1.0.0"),
|
|
3122
|
+
description=getattr(mounted_app, "description", ""),
|
|
3123
|
+
routes=mounted_app.routes,
|
|
3124
|
+
)
|
|
3125
|
+
|
|
3126
|
+
# Merge paths with prefix
|
|
3127
|
+
if "paths" in child_schema:
|
|
3128
|
+
for path, methods in child_schema["paths"].items():
|
|
3129
|
+
# Prepend path prefix
|
|
3130
|
+
prefixed_path = (
|
|
3131
|
+
f"{path_prefix}{path}" if path != "/" else path_prefix
|
|
3132
|
+
)
|
|
3133
|
+
openapi_schema["paths"][prefixed_path] = methods
|
|
3134
|
+
|
|
3135
|
+
# Merge components/schemas
|
|
3136
|
+
if "components" in child_schema:
|
|
3137
|
+
if "components" not in openapi_schema:
|
|
3138
|
+
openapi_schema["components"] = {}
|
|
3139
|
+
if "schemas" in child_schema["components"]:
|
|
3140
|
+
if "schemas" not in openapi_schema["components"]:
|
|
3141
|
+
openapi_schema["components"]["schemas"] = {}
|
|
3142
|
+
openapi_schema["components"]["schemas"].update(
|
|
3143
|
+
child_schema["components"]["schemas"]
|
|
3144
|
+
)
|
|
3145
|
+
|
|
3146
|
+
logger.debug(f"Aggregated OpenAPI schema from app '{app_slug}'")
|
|
3147
|
+
except (AttributeError, TypeError, KeyError, ValueError) as e:
|
|
3148
|
+
logger.warning(
|
|
3149
|
+
f"Failed to aggregate OpenAPI schema from app '{app_slug}': {e}"
|
|
3150
|
+
)
|
|
3151
|
+
break
|
|
3152
|
+
|
|
3153
|
+
parent_app.openapi_schema = openapi_schema
|
|
3154
|
+
return openapi_schema
|
|
3155
|
+
|
|
3156
|
+
parent_app.openapi = custom_openapi
|
|
3157
|
+
|
|
3158
|
+
# Add per-app docs endpoint
|
|
3159
|
+
@parent_app.get("/docs/{app_slug}")
|
|
3160
|
+
async def app_docs(app_slug: str):
|
|
3161
|
+
"""Get OpenAPI docs for a specific app."""
|
|
3162
|
+
from fastapi.openapi.docs import get_swagger_ui_html
|
|
3163
|
+
|
|
3164
|
+
# Find the app
|
|
3165
|
+
mounted_app = None
|
|
3166
|
+
path_prefix = None
|
|
3167
|
+
for mounted_app_info in mounted_apps:
|
|
3168
|
+
if mounted_app_info["slug"] == app_slug:
|
|
3169
|
+
path_prefix = mounted_app_info["path_prefix"]
|
|
3170
|
+
# Find the mounted app
|
|
3171
|
+
for route in parent_app.routes:
|
|
3172
|
+
if hasattr(route, "path") and route.path == path_prefix:
|
|
3173
|
+
if hasattr(route, "app"):
|
|
3174
|
+
mounted_app = route.app
|
|
3175
|
+
break
|
|
3176
|
+
break
|
|
3177
|
+
|
|
3178
|
+
if not mounted_app:
|
|
3179
|
+
from fastapi import HTTPException
|
|
3180
|
+
|
|
3181
|
+
raise HTTPException(404, f"App '{app_slug}' not found or not mounted")
|
|
3182
|
+
|
|
3183
|
+
# Generate OpenAPI JSON for this app
|
|
3184
|
+
from fastapi.openapi.utils import get_openapi
|
|
3185
|
+
|
|
3186
|
+
openapi_schema = get_openapi(
|
|
3187
|
+
title=getattr(mounted_app, "title", app_slug),
|
|
3188
|
+
version=getattr(mounted_app, "version", "1.0.0"),
|
|
3189
|
+
description=getattr(mounted_app, "description", ""),
|
|
3190
|
+
routes=mounted_app.routes,
|
|
3191
|
+
)
|
|
3192
|
+
|
|
3193
|
+
# Modify paths to include prefix
|
|
3194
|
+
if "paths" in openapi_schema:
|
|
3195
|
+
new_paths = {}
|
|
3196
|
+
for path, methods in openapi_schema["paths"].items():
|
|
3197
|
+
prefixed_path = f"{path_prefix}{path}" if path != "/" else path_prefix
|
|
3198
|
+
new_paths[prefixed_path] = methods
|
|
3199
|
+
openapi_schema["paths"] = new_paths
|
|
3200
|
+
|
|
3201
|
+
# Return Swagger UI HTML
|
|
3202
|
+
openapi_url = f"/_mdb/openapi/{app_slug}.json"
|
|
3203
|
+
|
|
3204
|
+
# Store schema temporarily for the JSON endpoint
|
|
3205
|
+
if not hasattr(parent_app.state, "app_openapi_schemas"):
|
|
3206
|
+
parent_app.state.app_openapi_schemas = {}
|
|
3207
|
+
parent_app.state.app_openapi_schemas[app_slug] = openapi_schema
|
|
3208
|
+
|
|
3209
|
+
return get_swagger_ui_html(
|
|
3210
|
+
openapi_url=openapi_url,
|
|
3211
|
+
title=f"{app_slug} - API Documentation",
|
|
3212
|
+
)
|
|
3213
|
+
|
|
3214
|
+
@parent_app.get("/_mdb/openapi/{app_slug}.json")
|
|
3215
|
+
async def app_openapi_json(app_slug: str):
|
|
3216
|
+
"""Get OpenAPI JSON for a specific app."""
|
|
3217
|
+
from fastapi import HTTPException
|
|
3218
|
+
|
|
3219
|
+
if not hasattr(parent_app.state, "app_openapi_schemas"):
|
|
3220
|
+
raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
|
|
3221
|
+
|
|
3222
|
+
schema = parent_app.state.app_openapi_schemas.get(app_slug)
|
|
3223
|
+
if not schema:
|
|
3224
|
+
raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
|
|
3225
|
+
|
|
3226
|
+
return schema
|
|
3227
|
+
|
|
3228
|
+
logger.info(f"Multi-app parent created with {len(apps)} app(s) configured")
|
|
3229
|
+
|
|
3230
|
+
return parent_app
|
|
3231
|
+
|
|
3232
|
+
def get_mounted_apps(self, app: Optional["FastAPI"] = None) -> list[dict[str, Any]]:
|
|
3233
|
+
"""
|
|
3234
|
+
Get metadata about all mounted apps.
|
|
3235
|
+
|
|
3236
|
+
Args:
|
|
3237
|
+
app: FastAPI app instance (optional, will use engine's tracked app if available)
|
|
3238
|
+
|
|
3239
|
+
Returns:
|
|
3240
|
+
List of dicts with app metadata:
|
|
3241
|
+
- slug: App slug
|
|
3242
|
+
- path_prefix: Path prefix where app is mounted
|
|
3243
|
+
- status: Mount status ("mounted", "failed", etc.)
|
|
3244
|
+
- manifest: App manifest (if available)
|
|
3245
|
+
- error: Error message (if status is "failed")
|
|
3246
|
+
|
|
3247
|
+
Example:
|
|
3248
|
+
mounted_apps = engine.get_mounted_apps(app)
|
|
3249
|
+
for app_info in mounted_apps:
|
|
3250
|
+
print(f"App {app_info['slug']} at {app_info['path_prefix']}")
|
|
3251
|
+
"""
|
|
3252
|
+
if app is None:
|
|
3253
|
+
# Try to get from engine state if available
|
|
3254
|
+
if hasattr(self, "_multi_app_instance"):
|
|
3255
|
+
app = self._multi_app_instance
|
|
3256
|
+
else:
|
|
3257
|
+
raise ValueError(
|
|
3258
|
+
"App instance required. Pass app parameter or use "
|
|
3259
|
+
"app.state.mounted_apps directly."
|
|
3260
|
+
)
|
|
3261
|
+
|
|
3262
|
+
mounted_apps = getattr(app.state, "mounted_apps", [])
|
|
3263
|
+
return mounted_apps
|
|
3264
|
+
|
|
3265
|
+
async def _initialize_shared_user_pool(
|
|
3266
|
+
self,
|
|
3267
|
+
app: "FastAPI",
|
|
3268
|
+
manifest: dict[str, Any] | None = None,
|
|
3269
|
+
) -> None:
|
|
3270
|
+
"""
|
|
3271
|
+
Initialize shared user pool, audit log, and set them on app.state.
|
|
3272
|
+
|
|
3273
|
+
Called during lifespan startup for apps using "shared" auth mode.
|
|
3274
|
+
The lazy middleware (added at app creation time) will read the
|
|
3275
|
+
user_pool from app.state at request time.
|
|
3276
|
+
|
|
3277
|
+
Security Features:
|
|
3278
|
+
- JWT secret required (fails fast if not configured)
|
|
3279
|
+
- allow_insecure_dev mode for local development only
|
|
3280
|
+
- Audit logging for compliance and forensics
|
|
3281
|
+
|
|
3282
|
+
Args:
|
|
3283
|
+
app: FastAPI application instance
|
|
3284
|
+
manifest: Optional manifest dict for seeding demo users
|
|
3285
|
+
"""
|
|
3286
|
+
from ..auth.audit import AuthAuditLog
|
|
3287
|
+
from ..auth.shared_users import SharedUserPool
|
|
3288
|
+
|
|
3289
|
+
# Determine if we're in development mode
|
|
3290
|
+
# Development = allow insecure auto-generated JWT secret
|
|
3291
|
+
is_dev = (
|
|
3292
|
+
os.getenv("MDB_ENGINE_ENV", "").lower() in ("dev", "development", "local")
|
|
3293
|
+
or os.getenv("ENVIRONMENT", "").lower() in ("dev", "development", "local")
|
|
3294
|
+
or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
3295
|
+
)
|
|
3296
|
+
|
|
3297
|
+
# Thread-safe initialization with async lock to prevent race conditions
|
|
3298
|
+
async with self._shared_user_pool_lock:
|
|
3299
|
+
# Check if another coroutine is initializing
|
|
3300
|
+
if self._shared_user_pool_initializing:
|
|
3301
|
+
# Wait for other initialization to complete
|
|
3302
|
+
while self._shared_user_pool_initializing:
|
|
3303
|
+
import asyncio
|
|
3304
|
+
|
|
3305
|
+
await asyncio.sleep(0.01) # Small delay to avoid busy-waiting
|
|
3306
|
+
# After waiting, check if pool was initialized
|
|
3307
|
+
if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
|
|
3308
|
+
app.state.user_pool = self._shared_user_pool
|
|
3309
|
+
return
|
|
3310
|
+
|
|
3311
|
+
# Check if already initialized (double-check pattern)
|
|
3312
|
+
if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
|
|
3313
|
+
app.state.user_pool = self._shared_user_pool
|
|
3314
|
+
return
|
|
3315
|
+
|
|
3316
|
+
# Mark as initializing
|
|
3317
|
+
self._shared_user_pool_initializing = True
|
|
3318
|
+
try:
|
|
3319
|
+
# Create shared user pool
|
|
3320
|
+
self._shared_user_pool = SharedUserPool(
|
|
3321
|
+
self._connection_manager.mongo_db,
|
|
3322
|
+
allow_insecure_dev=is_dev,
|
|
3323
|
+
)
|
|
3324
|
+
await self._shared_user_pool.ensure_indexes()
|
|
3325
|
+
logger.info("SharedUserPool initialized")
|
|
3326
|
+
|
|
3327
|
+
# Expose user pool on app.state for middleware to access
|
|
3328
|
+
app.state.user_pool = self._shared_user_pool
|
|
3329
|
+
finally:
|
|
3330
|
+
# Always clear the initializing flag
|
|
3331
|
+
self._shared_user_pool_initializing = False
|
|
3332
|
+
|
|
3333
|
+
# Seed demo users to SharedUserPool if configured in manifest
|
|
3334
|
+
if manifest:
|
|
3335
|
+
auth_config = manifest.get("auth", {})
|
|
3336
|
+
users_config = auth_config.get("users", {})
|
|
3337
|
+
demo_users = users_config.get("demo_users", [])
|
|
3338
|
+
|
|
3339
|
+
if demo_users and users_config.get("demo_user_seed_strategy", "auto") != "disabled":
|
|
3340
|
+
for demo in demo_users:
|
|
3341
|
+
try:
|
|
3342
|
+
email = demo.get("email")
|
|
3343
|
+
password = demo.get("password")
|
|
3344
|
+
app_roles = demo.get("app_roles", {})
|
|
3345
|
+
|
|
3346
|
+
existing = await self._shared_user_pool.get_user_by_email(email)
|
|
3347
|
+
|
|
3348
|
+
if not existing:
|
|
3349
|
+
await self._shared_user_pool.create_user(
|
|
3350
|
+
email=email,
|
|
3351
|
+
password=password,
|
|
3352
|
+
app_roles=app_roles,
|
|
3353
|
+
)
|
|
3354
|
+
logger.info(f"✅ Created shared demo user: {email}")
|
|
3355
|
+
else:
|
|
3356
|
+
logger.debug(f"ℹ️ Shared demo user exists: {email}")
|
|
3357
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
3358
|
+
logger.warning(
|
|
3359
|
+
f"⚠️ Failed to create shared demo user {demo.get('email')}: {e}"
|
|
3360
|
+
)
|
|
3361
|
+
|
|
3362
|
+
# Initialize audit logging if enabled
|
|
3363
|
+
auth_config = (manifest or {}).get("auth", {})
|
|
3364
|
+
audit_config = auth_config.get("audit", {})
|
|
3365
|
+
audit_enabled = audit_config.get("enabled", True) # Default: enabled for shared auth
|
|
3366
|
+
|
|
3367
|
+
if audit_enabled:
|
|
3368
|
+
retention_days = audit_config.get("retention_days", 90)
|
|
3369
|
+
if not hasattr(self, "_auth_audit_log") or self._auth_audit_log is None:
|
|
3370
|
+
self._auth_audit_log = AuthAuditLog(
|
|
3371
|
+
self._connection_manager.mongo_db,
|
|
3372
|
+
retention_days=retention_days,
|
|
3373
|
+
)
|
|
3374
|
+
await self._auth_audit_log.ensure_indexes()
|
|
3375
|
+
logger.info(f"AuthAuditLog initialized (retention: {retention_days} days)")
|
|
3376
|
+
|
|
3377
|
+
app.state.audit_log = self._auth_audit_log
|
|
3378
|
+
|
|
3379
|
+
logger.info("SharedUserPool and AuditLog attached to app.state")
|
|
3380
|
+
|
|
3381
|
+
def lifespan(
|
|
3382
|
+
self,
|
|
3383
|
+
slug: str,
|
|
3384
|
+
manifest: Path,
|
|
3385
|
+
) -> Callable:
|
|
3386
|
+
"""
|
|
3387
|
+
Create a lifespan context manager for use with FastAPI.
|
|
3388
|
+
|
|
3389
|
+
Use this when you want more control over FastAPI app creation
|
|
3390
|
+
but still want automatic engine lifecycle management.
|
|
3391
|
+
|
|
3392
|
+
Args:
|
|
3393
|
+
slug: Application slug
|
|
3394
|
+
manifest: Path to manifest.json file
|
|
3395
|
+
|
|
3396
|
+
Returns:
|
|
3397
|
+
Async context manager for FastAPI lifespan
|
|
3398
|
+
|
|
3399
|
+
Example:
|
|
3400
|
+
engine = MongoDBEngine(...)
|
|
3401
|
+
app = FastAPI(lifespan=engine.lifespan("my_app", Path("manifest.json")))
|
|
3402
|
+
"""
|
|
3403
|
+
engine = self
|
|
3404
|
+
manifest_path = Path(manifest)
|
|
3405
|
+
|
|
3406
|
+
@asynccontextmanager
|
|
3407
|
+
async def _lifespan(app: Any):
|
|
3408
|
+
"""Lifespan context manager."""
|
|
3409
|
+
# Initialize engine
|
|
3410
|
+
await engine.initialize()
|
|
3411
|
+
|
|
3412
|
+
# Load and register manifest
|
|
3413
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
3414
|
+
await engine.register_app(app_manifest)
|
|
3415
|
+
|
|
3416
|
+
# Auto-retrieve app token
|
|
3417
|
+
await engine.auto_retrieve_app_token(slug)
|
|
3418
|
+
|
|
3419
|
+
# Expose on app.state
|
|
3420
|
+
app.state.engine = engine
|
|
3421
|
+
app.state.app_slug = slug
|
|
3422
|
+
app.state.manifest = app_manifest
|
|
3423
|
+
|
|
3424
|
+
yield
|
|
3425
|
+
|
|
3426
|
+
await engine.shutdown()
|
|
3427
|
+
|
|
3428
|
+
return _lifespan
|
|
3429
|
+
|
|
3430
|
+
async def auto_retrieve_app_token(self, slug: str) -> str | None:
|
|
3431
|
+
"""
|
|
3432
|
+
Auto-retrieve app token from environment or database.
|
|
3433
|
+
|
|
3434
|
+
Follows convention: {SLUG_UPPER}_SECRET environment variable.
|
|
3435
|
+
Falls back to database retrieval via secrets manager.
|
|
3436
|
+
|
|
3437
|
+
Args:
|
|
3438
|
+
slug: Application slug
|
|
3439
|
+
|
|
3440
|
+
Returns:
|
|
3441
|
+
App token if found, None otherwise
|
|
3442
|
+
|
|
3443
|
+
Example:
|
|
3444
|
+
# Set MY_APP_SECRET environment variable, or
|
|
3445
|
+
# let the engine retrieve from database
|
|
3446
|
+
token = await engine.auto_retrieve_app_token("my_app")
|
|
3447
|
+
"""
|
|
3448
|
+
# Check cache first
|
|
3449
|
+
if slug in self._app_token_cache:
|
|
3450
|
+
logger.debug(f"Using cached token for '{slug}'")
|
|
3451
|
+
return self._app_token_cache[slug]
|
|
3452
|
+
|
|
3453
|
+
# Try environment variable first (convention: {SLUG}_SECRET)
|
|
3454
|
+
env_var_name = f"{slug.upper().replace('-', '_')}_SECRET"
|
|
3455
|
+
token = os.getenv(env_var_name)
|
|
3456
|
+
|
|
3457
|
+
if token:
|
|
3458
|
+
logger.info(f"App token for '{slug}' loaded from {env_var_name}")
|
|
3459
|
+
self._app_token_cache[slug] = token
|
|
3460
|
+
return token
|
|
3461
|
+
|
|
3462
|
+
# Try to retrieve from database
|
|
3463
|
+
if self._app_secrets_manager:
|
|
3464
|
+
try:
|
|
3465
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
3466
|
+
if secret_exists:
|
|
3467
|
+
token = await self._app_secrets_manager.get_app_secret(slug)
|
|
3468
|
+
if token:
|
|
3469
|
+
logger.info(f"App token for '{slug}' retrieved from database")
|
|
3470
|
+
self._app_token_cache[slug] = token
|
|
3471
|
+
return token
|
|
3472
|
+
else:
|
|
3473
|
+
logger.debug(f"No stored secret found for '{slug}'")
|
|
3474
|
+
except PyMongoError as e:
|
|
3475
|
+
logger.warning(f"Error retrieving app token for '{slug}': {e}")
|
|
3476
|
+
|
|
3477
|
+
logger.debug(
|
|
3478
|
+
f"No app token found for '{slug}'. "
|
|
3479
|
+
f"Set {env_var_name} environment variable or register app to generate one."
|
|
3480
|
+
)
|
|
3481
|
+
return None
|
|
3482
|
+
|
|
3483
|
+
def get_app_token(self, slug: str) -> str | None:
|
|
3484
|
+
"""
|
|
3485
|
+
Get cached app token for a slug.
|
|
3486
|
+
|
|
3487
|
+
Returns token from cache if available. Use auto_retrieve_app_token()
|
|
3488
|
+
to populate the cache first.
|
|
3489
|
+
|
|
3490
|
+
Args:
|
|
3491
|
+
slug: Application slug
|
|
3492
|
+
|
|
3493
|
+
Returns:
|
|
3494
|
+
Cached app token or None
|
|
3495
|
+
"""
|
|
3496
|
+
return self._app_token_cache.get(slug)
|