mdb-engine 0.1.6__py3-none-any.whl → 0.1.7__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 +38 -6
- mdb_engine/auth/README.md +534 -11
- mdb_engine/auth/__init__.py +129 -28
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/casbin_factory.py +10 -14
- mdb_engine/auth/config_helpers.py +7 -6
- mdb_engine/auth/cookie_utils.py +3 -7
- mdb_engine/auth/csrf.py +373 -0
- mdb_engine/auth/decorators.py +3 -10
- mdb_engine/auth/dependencies.py +37 -45
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +30 -73
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +16 -36
- mdb_engine/auth/provider.py +17 -38
- mdb_engine/auth/rate_limiter.py +504 -0
- mdb_engine/auth/restrictions.py +8 -24
- mdb_engine/auth/session_manager.py +14 -29
- mdb_engine/auth/shared_middleware.py +600 -0
- mdb_engine/auth/shared_users.py +759 -0
- mdb_engine/auth/token_store.py +14 -28
- mdb_engine/auth/users.py +54 -113
- mdb_engine/auth/utils.py +213 -15
- mdb_engine/cli/commands/generate.py +545 -9
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +3 -3
- mdb_engine/config.py +7 -21
- 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 +22 -41
- mdb_engine/core/app_secrets.py +290 -0
- mdb_engine/core/connection.py +18 -9
- mdb_engine/core/encryption.py +223 -0
- mdb_engine/core/engine.py +758 -95
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +424 -135
- mdb_engine/core/ray_integration.py +435 -0
- mdb_engine/core/seeding.py +10 -18
- mdb_engine/core/service_initialization.py +12 -23
- mdb_engine/core/types.py +2 -5
- mdb_engine/database/README.md +112 -16
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +25 -37
- mdb_engine/database/connection.py +11 -18
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +713 -196
- mdb_engine/embeddings/__init__.py +17 -9
- mdb_engine/embeddings/dependencies.py +1 -3
- mdb_engine/embeddings/service.py +11 -25
- mdb_engine/exceptions.py +92 -0
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +1 -1
- mdb_engine/indexes/manager.py +50 -114
- mdb_engine/memory/README.md +2 -2
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +30 -87
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +8 -9
- mdb_engine/observability/metrics.py +32 -12
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +25 -60
- mdb_engine-0.1.7.dist-info/METADATA +285 -0
- mdb_engine-0.1.7.dist-info/RECORD +85 -0
- 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.1.7.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.1.7.dist-info}/top_level.txt +0 -0
mdb_engine/core/engine.py
CHANGED
|
@@ -7,28 +7,54 @@ 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 contextlib import asynccontextmanager
|
|
15
32
|
from pathlib import Path
|
|
16
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
33
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
|
|
17
34
|
|
|
18
|
-
from motor.motor_asyncio import AsyncIOMotorClient
|
|
35
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
36
|
+
from pymongo.errors import PyMongoError
|
|
19
37
|
|
|
20
38
|
if TYPE_CHECKING:
|
|
39
|
+
from fastapi import FastAPI
|
|
40
|
+
|
|
21
41
|
from ..auth import AuthorizationProvider
|
|
22
42
|
from .types import ManifestDict
|
|
23
43
|
|
|
24
44
|
# Import engine components
|
|
25
45
|
from ..constants import DEFAULT_MAX_POOL_SIZE, DEFAULT_MIN_POOL_SIZE
|
|
26
46
|
from ..database import ScopedMongoWrapper
|
|
27
|
-
from ..observability import (
|
|
28
|
-
|
|
47
|
+
from ..observability import (
|
|
48
|
+
HealthChecker,
|
|
49
|
+
check_engine_health,
|
|
50
|
+
check_mongodb_health,
|
|
51
|
+
check_pool_health,
|
|
52
|
+
)
|
|
29
53
|
from ..observability import get_logger as get_contextual_logger
|
|
30
54
|
from .app_registration import AppRegistrationManager
|
|
55
|
+
from .app_secrets import AppSecretsManager
|
|
31
56
|
from .connection import ConnectionManager
|
|
57
|
+
from .encryption import EnvelopeEncryptionService
|
|
32
58
|
from .index_management import IndexManager
|
|
33
59
|
from .manifest import ManifestParser, ManifestValidator
|
|
34
60
|
from .service_initialization import ServiceInitializer
|
|
@@ -48,6 +74,20 @@ class MongoDBEngine:
|
|
|
48
74
|
- App registration
|
|
49
75
|
- Index management
|
|
50
76
|
- Authentication/authorization setup
|
|
77
|
+
- Optional Ray integration for distributed processing
|
|
78
|
+
- FastAPI integration with lifespan management
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
# Simple usage
|
|
82
|
+
engine = MongoDBEngine(mongo_uri="mongodb://localhost:27017", db_name="mydb")
|
|
83
|
+
await engine.initialize()
|
|
84
|
+
db = engine.get_scoped_db("my_app")
|
|
85
|
+
|
|
86
|
+
# With FastAPI
|
|
87
|
+
app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
|
|
88
|
+
|
|
89
|
+
# With Ray
|
|
90
|
+
engine = MongoDBEngine(..., enable_ray=True)
|
|
51
91
|
"""
|
|
52
92
|
|
|
53
93
|
def __init__(
|
|
@@ -58,6 +98,9 @@ class MongoDBEngine:
|
|
|
58
98
|
authz_provider: Optional["AuthorizationProvider"] = None,
|
|
59
99
|
max_pool_size: int = DEFAULT_MAX_POOL_SIZE,
|
|
60
100
|
min_pool_size: int = DEFAULT_MIN_POOL_SIZE,
|
|
101
|
+
# Optional Ray support
|
|
102
|
+
enable_ray: bool = False,
|
|
103
|
+
ray_namespace: str = "modular_labs",
|
|
61
104
|
) -> None:
|
|
62
105
|
"""
|
|
63
106
|
Initialize the MongoDB Engine.
|
|
@@ -69,6 +112,10 @@ class MongoDBEngine:
|
|
|
69
112
|
authz_provider: Authorization provider instance (optional, can be set later)
|
|
70
113
|
max_pool_size: Maximum MongoDB connection pool size
|
|
71
114
|
min_pool_size: Minimum MongoDB connection pool size
|
|
115
|
+
enable_ray: Enable Ray support for distributed processing.
|
|
116
|
+
Default: False. Only activates if Ray is installed.
|
|
117
|
+
ray_namespace: Ray namespace for actor isolation.
|
|
118
|
+
Default: "modular_labs"
|
|
72
119
|
"""
|
|
73
120
|
self.mongo_uri = mongo_uri
|
|
74
121
|
self.db_name = db_name
|
|
@@ -77,6 +124,11 @@ class MongoDBEngine:
|
|
|
77
124
|
self.max_pool_size = max_pool_size
|
|
78
125
|
self.min_pool_size = min_pool_size
|
|
79
126
|
|
|
127
|
+
# Ray configuration (optional)
|
|
128
|
+
self.enable_ray = enable_ray
|
|
129
|
+
self.ray_namespace = ray_namespace
|
|
130
|
+
self.ray_actor = None # Populated if Ray is enabled and available
|
|
131
|
+
|
|
80
132
|
# Initialize component managers
|
|
81
133
|
self._connection_manager = ConnectionManager(
|
|
82
134
|
mongo_uri=mongo_uri,
|
|
@@ -93,6 +145,14 @@ class MongoDBEngine:
|
|
|
93
145
|
self._app_registration_manager: Optional[AppRegistrationManager] = None
|
|
94
146
|
self._index_manager: Optional[IndexManager] = None
|
|
95
147
|
self._service_initializer: Optional[ServiceInitializer] = None
|
|
148
|
+
self._encryption_service: Optional[EnvelopeEncryptionService] = None
|
|
149
|
+
self._app_secrets_manager: Optional[AppSecretsManager] = None
|
|
150
|
+
|
|
151
|
+
# Store app read_scopes mapping for validation
|
|
152
|
+
self._app_read_scopes: Dict[str, List[str]] = {}
|
|
153
|
+
|
|
154
|
+
# Store app token cache for auto-retrieval
|
|
155
|
+
self._app_token_cache: Dict[str, str] = {}
|
|
96
156
|
|
|
97
157
|
async def initialize(self) -> None:
|
|
98
158
|
"""
|
|
@@ -102,6 +162,7 @@ class MongoDBEngine:
|
|
|
102
162
|
1. Connects to MongoDB
|
|
103
163
|
2. Validates the connection
|
|
104
164
|
3. Sets up initial state
|
|
165
|
+
4. Initializes Ray if enabled and available
|
|
105
166
|
|
|
106
167
|
Raises:
|
|
107
168
|
InitializationError: If initialization fails (subclass of RuntimeError
|
|
@@ -111,6 +172,29 @@ class MongoDBEngine:
|
|
|
111
172
|
# Initialize connection
|
|
112
173
|
await self._connection_manager.initialize()
|
|
113
174
|
|
|
175
|
+
# Initialize encryption service
|
|
176
|
+
try:
|
|
177
|
+
from .encryption import MASTER_KEY_ENV_VAR
|
|
178
|
+
|
|
179
|
+
self._encryption_service = EnvelopeEncryptionService()
|
|
180
|
+
except ValueError as e:
|
|
181
|
+
from .encryption import MASTER_KEY_ENV_VAR
|
|
182
|
+
|
|
183
|
+
logger.warning(
|
|
184
|
+
f"Encryption service not initialized: {e}. "
|
|
185
|
+
"App-level authentication will not be available. "
|
|
186
|
+
f"Set {MASTER_KEY_ENV_VAR} environment variable."
|
|
187
|
+
)
|
|
188
|
+
# Continue without encryption (backward compatibility)
|
|
189
|
+
self._encryption_service = None
|
|
190
|
+
|
|
191
|
+
# Initialize app secrets manager (only if encryption service available)
|
|
192
|
+
if self._encryption_service:
|
|
193
|
+
self._app_secrets_manager = AppSecretsManager(
|
|
194
|
+
mongo_db=self._connection_manager.mongo_db,
|
|
195
|
+
encryption_service=self._encryption_service,
|
|
196
|
+
)
|
|
197
|
+
|
|
114
198
|
# Set up component managers
|
|
115
199
|
self._app_registration_manager = AppRegistrationManager(
|
|
116
200
|
mongo_db=self._connection_manager.mongo_db,
|
|
@@ -126,31 +210,73 @@ class MongoDBEngine:
|
|
|
126
210
|
get_scoped_db_fn=self.get_scoped_db,
|
|
127
211
|
)
|
|
128
212
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Get the MongoDB client.
|
|
213
|
+
# Initialize Ray if enabled
|
|
214
|
+
if self.enable_ray:
|
|
215
|
+
await self._initialize_ray()
|
|
133
216
|
|
|
134
|
-
|
|
135
|
-
|
|
217
|
+
async def _initialize_ray(self) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Initialize Ray support (only if enabled and available).
|
|
136
220
|
|
|
137
|
-
|
|
138
|
-
|
|
221
|
+
This is called automatically during initialize() if enable_ray=True.
|
|
222
|
+
Gracefully degrades if Ray is not installed.
|
|
139
223
|
"""
|
|
140
|
-
|
|
224
|
+
try:
|
|
225
|
+
from .ray_integration import RAY_AVAILABLE, get_ray_actor_handle
|
|
226
|
+
|
|
227
|
+
if not RAY_AVAILABLE:
|
|
228
|
+
logger.warning("Ray enabled but not installed. " "Install with: pip install ray")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Initialize base Ray actor for this engine
|
|
232
|
+
self.ray_actor = await get_ray_actor_handle(
|
|
233
|
+
app_slug="engine",
|
|
234
|
+
namespace=self.ray_namespace,
|
|
235
|
+
mongo_uri=self.mongo_uri,
|
|
236
|
+
db_name=self.db_name,
|
|
237
|
+
create_if_missing=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if self.ray_actor:
|
|
241
|
+
logger.info(f"Ray initialized in namespace '{self.ray_namespace}'")
|
|
242
|
+
else:
|
|
243
|
+
logger.warning("Failed to initialize Ray actor")
|
|
244
|
+
|
|
245
|
+
except ImportError:
|
|
246
|
+
logger.warning("Ray integration module not available")
|
|
141
247
|
|
|
142
248
|
@property
|
|
143
|
-
def
|
|
249
|
+
def has_ray(self) -> bool:
|
|
250
|
+
"""Check if Ray is enabled and initialized."""
|
|
251
|
+
return self.enable_ray and self.ray_actor is not None
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def mongo_client(self) -> AsyncIOMotorClient:
|
|
144
255
|
"""
|
|
145
|
-
Get the MongoDB
|
|
256
|
+
Get the MongoDB client for observability and health checks.
|
|
257
|
+
|
|
258
|
+
**SECURITY WARNING:** This property exposes the raw MongoDB client.
|
|
259
|
+
It should ONLY be used for:
|
|
260
|
+
- Health checks and observability (`check_mongodb_health`, `get_pool_metrics`)
|
|
261
|
+
- Administrative operations that don't involve data access
|
|
262
|
+
|
|
263
|
+
**DO NOT use this for data access.** Always use `get_scoped_db()` for
|
|
264
|
+
all data operations to ensure proper app scoping and security.
|
|
146
265
|
|
|
147
266
|
Returns:
|
|
148
|
-
|
|
267
|
+
AsyncIOMotorClient instance
|
|
149
268
|
|
|
150
269
|
Raises:
|
|
151
270
|
RuntimeError: If engine is not initialized
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
# ✅ CORRECT: Use for health checks
|
|
274
|
+
health = await check_mongodb_health(engine.mongo_client)
|
|
275
|
+
|
|
276
|
+
# ❌ WRONG: Don't use for data access
|
|
277
|
+
db = engine.mongo_client["my_database"] # Bypasses scoping!
|
|
152
278
|
"""
|
|
153
|
-
return self._connection_manager.
|
|
279
|
+
return self._connection_manager.mongo_client
|
|
154
280
|
|
|
155
281
|
@property
|
|
156
282
|
def _initialized(self) -> bool:
|
|
@@ -160,6 +286,7 @@ class MongoDBEngine:
|
|
|
160
286
|
def get_scoped_db(
|
|
161
287
|
self,
|
|
162
288
|
app_slug: str,
|
|
289
|
+
app_token: Optional[str] = None,
|
|
163
290
|
read_scopes: Optional[List[str]] = None,
|
|
164
291
|
write_scope: Optional[str] = None,
|
|
165
292
|
auto_index: bool = True,
|
|
@@ -174,8 +301,12 @@ class MongoDBEngine:
|
|
|
174
301
|
|
|
175
302
|
Args:
|
|
176
303
|
app_slug: App slug (used as default for both read and write scopes)
|
|
177
|
-
|
|
178
|
-
|
|
304
|
+
app_token: App secret token for authentication. Required if app
|
|
305
|
+
secrets manager is initialized. If None and app has stored secret,
|
|
306
|
+
will attempt migration (backward compatibility).
|
|
307
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
308
|
+
read_scopes or defaults to [app_slug]. Allows cross-app data access
|
|
309
|
+
when needed.
|
|
179
310
|
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
180
311
|
All documents inserted through this wrapper will have this as their
|
|
181
312
|
app_id.
|
|
@@ -187,27 +318,210 @@ class MongoDBEngine:
|
|
|
187
318
|
|
|
188
319
|
Raises:
|
|
189
320
|
RuntimeError: If engine is not initialized.
|
|
321
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
190
322
|
|
|
191
323
|
Example:
|
|
192
|
-
>>> db = engine.get_scoped_db("my_app")
|
|
324
|
+
>>> db = engine.get_scoped_db("my_app", app_token="secret-token")
|
|
193
325
|
>>> # All queries are automatically scoped to "my_app"
|
|
194
326
|
>>> doc = await db.my_collection.find_one({"name": "test"})
|
|
195
327
|
"""
|
|
196
328
|
if not self._initialized:
|
|
197
|
-
raise RuntimeError(
|
|
198
|
-
|
|
199
|
-
|
|
329
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
330
|
+
|
|
331
|
+
# Verify app token if secrets manager is available
|
|
332
|
+
# Token verification will happen lazily in ScopedMongoWrapper if called from async context
|
|
333
|
+
if self._app_secrets_manager:
|
|
334
|
+
if app_token is None:
|
|
335
|
+
# Check if app has stored secret (backward compatibility)
|
|
336
|
+
# Use sync wrapper that handles async context
|
|
337
|
+
has_secret = self._app_secrets_manager.app_secret_exists_sync(app_slug)
|
|
338
|
+
if has_secret:
|
|
339
|
+
# Log detailed info
|
|
340
|
+
logger.warning(f"App token required for '{app_slug}'")
|
|
341
|
+
# Generic error message
|
|
342
|
+
raise ValueError("App token required. Provide app_token parameter.")
|
|
343
|
+
# No stored secret - allow (backward compatibility for apps without secrets)
|
|
344
|
+
logger.debug(
|
|
345
|
+
f"App '{app_slug}' has no stored secret, "
|
|
346
|
+
f"allowing access (backward compatibility)"
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
# Try to verify synchronously if possible, otherwise pass to wrapper
|
|
350
|
+
# for lazy verification
|
|
351
|
+
import asyncio
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
# Check if we're in an async context
|
|
355
|
+
asyncio.get_running_loop()
|
|
356
|
+
# We're in async context - can't verify synchronously without blocking
|
|
357
|
+
# Pass token to wrapper for lazy verification on first database operation
|
|
358
|
+
logger.debug(
|
|
359
|
+
f"Token verification deferred to first database operation for '{app_slug}' "
|
|
360
|
+
f"(async context detected)"
|
|
361
|
+
)
|
|
362
|
+
except RuntimeError:
|
|
363
|
+
# No event loop - safe to use sync verification
|
|
364
|
+
is_valid = self._app_secrets_manager.verify_app_secret_sync(app_slug, app_token)
|
|
365
|
+
if not is_valid:
|
|
366
|
+
# Log detailed info with app_slug
|
|
367
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
368
|
+
# Generic error message (from None: unrelated to RuntimeError)
|
|
369
|
+
raise ValueError("Invalid app token") from None
|
|
370
|
+
|
|
371
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
372
|
+
if read_scopes is not None:
|
|
373
|
+
if not isinstance(read_scopes, list):
|
|
374
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
375
|
+
if len(read_scopes) == 0:
|
|
376
|
+
raise ValueError("read_scopes cannot be empty")
|
|
377
|
+
|
|
378
|
+
# Use manifest read_scopes if not provided
|
|
379
|
+
if read_scopes is None:
|
|
380
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
381
|
+
|
|
382
|
+
if write_scope is None:
|
|
383
|
+
write_scope = app_slug
|
|
384
|
+
|
|
385
|
+
# Validate requested read_scopes against manifest authorization
|
|
386
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
387
|
+
for scope in read_scopes:
|
|
388
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
389
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
390
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
391
|
+
if scope not in authorized_scopes:
|
|
392
|
+
logger.warning(
|
|
393
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
394
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
395
|
+
)
|
|
396
|
+
raise ValueError(
|
|
397
|
+
"App not authorized to read from requested scope. "
|
|
398
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
399
|
+
)
|
|
400
|
+
if not read_scopes:
|
|
401
|
+
raise ValueError("read_scopes cannot be empty")
|
|
402
|
+
for scope in read_scopes:
|
|
403
|
+
if not isinstance(scope, str) or not scope:
|
|
404
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
405
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
406
|
+
|
|
407
|
+
# Validate write_scope
|
|
408
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
409
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
410
|
+
|
|
411
|
+
return ScopedMongoWrapper(
|
|
412
|
+
real_db=self._connection_manager.mongo_db,
|
|
413
|
+
read_scopes=read_scopes,
|
|
414
|
+
write_scope=write_scope,
|
|
415
|
+
auto_index=auto_index,
|
|
416
|
+
app_slug=app_slug,
|
|
417
|
+
app_token=app_token,
|
|
418
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def get_scoped_db_async(
|
|
422
|
+
self,
|
|
423
|
+
app_slug: str,
|
|
424
|
+
app_token: Optional[str] = None,
|
|
425
|
+
read_scopes: Optional[List[str]] = None,
|
|
426
|
+
write_scope: Optional[str] = None,
|
|
427
|
+
auto_index: bool = True,
|
|
428
|
+
) -> ScopedMongoWrapper:
|
|
429
|
+
"""
|
|
430
|
+
Asynchronous version of get_scoped_db that properly verifies tokens.
|
|
431
|
+
|
|
432
|
+
This method is preferred in async contexts to ensure token verification
|
|
433
|
+
happens correctly.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
app_slug: App slug (used as default for both read and write scopes)
|
|
437
|
+
app_token: App secret token for authentication. Required if app
|
|
438
|
+
secrets manager is initialized.
|
|
439
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
440
|
+
read_scopes or defaults to [app_slug].
|
|
441
|
+
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
442
|
+
auto_index: Whether to enable automatic index creation.
|
|
200
443
|
|
|
444
|
+
Returns:
|
|
445
|
+
ScopedMongoWrapper instance configured with the specified scopes.
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
RuntimeError: If engine is not initialized.
|
|
449
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
450
|
+
"""
|
|
451
|
+
if not self._initialized:
|
|
452
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
453
|
+
|
|
454
|
+
# Verify app token if secrets manager is available
|
|
455
|
+
if self._app_secrets_manager:
|
|
456
|
+
if app_token is None:
|
|
457
|
+
# Check if app has stored secret
|
|
458
|
+
has_secret = await self._app_secrets_manager.app_secret_exists(app_slug)
|
|
459
|
+
if has_secret:
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"App token required for '{app_slug}'. " "Provide app_token parameter."
|
|
462
|
+
)
|
|
463
|
+
# No stored secret - allow (backward compatibility)
|
|
464
|
+
logger.debug(
|
|
465
|
+
f"App '{app_slug}' has no stored secret, "
|
|
466
|
+
f"allowing access (backward compatibility)"
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
# Verify token asynchronously
|
|
470
|
+
is_valid = await self._app_secrets_manager.verify_app_secret(app_slug, app_token)
|
|
471
|
+
if not is_valid:
|
|
472
|
+
# Log detailed info with app_slug
|
|
473
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
474
|
+
# Generic error message
|
|
475
|
+
raise ValueError("Invalid app token")
|
|
476
|
+
|
|
477
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
478
|
+
if read_scopes is not None:
|
|
479
|
+
if not isinstance(read_scopes, list):
|
|
480
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
481
|
+
if len(read_scopes) == 0:
|
|
482
|
+
raise ValueError("read_scopes cannot be empty")
|
|
483
|
+
|
|
484
|
+
# Use manifest read_scopes if not provided
|
|
201
485
|
if read_scopes is None:
|
|
202
|
-
read_scopes = [app_slug]
|
|
486
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
487
|
+
|
|
203
488
|
if write_scope is None:
|
|
204
489
|
write_scope = app_slug
|
|
205
490
|
|
|
491
|
+
# Validate requested read_scopes against manifest authorization
|
|
492
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
493
|
+
for scope in read_scopes:
|
|
494
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
495
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
496
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
497
|
+
if scope not in authorized_scopes:
|
|
498
|
+
logger.warning(
|
|
499
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
500
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
501
|
+
)
|
|
502
|
+
raise ValueError(
|
|
503
|
+
"App not authorized to read from requested scope. "
|
|
504
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
505
|
+
)
|
|
506
|
+
if not read_scopes:
|
|
507
|
+
raise ValueError("read_scopes cannot be empty")
|
|
508
|
+
for scope in read_scopes:
|
|
509
|
+
if not isinstance(scope, str) or not scope:
|
|
510
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
511
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
512
|
+
|
|
513
|
+
# Validate write_scope
|
|
514
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
515
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
516
|
+
|
|
206
517
|
return ScopedMongoWrapper(
|
|
207
518
|
real_db=self._connection_manager.mongo_db,
|
|
208
519
|
read_scopes=read_scopes,
|
|
209
520
|
write_scope=write_scope,
|
|
210
521
|
auto_index=auto_index,
|
|
522
|
+
app_slug=app_slug,
|
|
523
|
+
app_token=app_token,
|
|
524
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
211
525
|
)
|
|
212
526
|
|
|
213
527
|
async def validate_manifest(
|
|
@@ -227,9 +541,7 @@ class MongoDBEngine:
|
|
|
227
541
|
- error_paths: List of JSON paths with validation errors, None if valid
|
|
228
542
|
"""
|
|
229
543
|
if not self._app_registration_manager:
|
|
230
|
-
raise RuntimeError(
|
|
231
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
232
|
-
)
|
|
544
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
233
545
|
return await self._app_registration_manager.validate_manifest(manifest)
|
|
234
546
|
|
|
235
547
|
async def load_manifest(self, path: Path) -> "ManifestDict":
|
|
@@ -247,14 +559,10 @@ class MongoDBEngine:
|
|
|
247
559
|
ValueError: If validation fails
|
|
248
560
|
"""
|
|
249
561
|
if not self._app_registration_manager:
|
|
250
|
-
raise RuntimeError(
|
|
251
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
252
|
-
)
|
|
562
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
253
563
|
return await self._app_registration_manager.load_manifest(path)
|
|
254
564
|
|
|
255
|
-
async def register_app(
|
|
256
|
-
self, manifest: "ManifestDict", create_indexes: bool = True
|
|
257
|
-
) -> bool:
|
|
565
|
+
async def register_app(self, manifest: "ManifestDict", create_indexes: bool = True) -> bool:
|
|
258
566
|
"""
|
|
259
567
|
Register an app from its manifest.
|
|
260
568
|
|
|
@@ -275,9 +583,7 @@ class MongoDBEngine:
|
|
|
275
583
|
RuntimeError: If engine is not initialized.
|
|
276
584
|
"""
|
|
277
585
|
if not self._app_registration_manager:
|
|
278
|
-
raise RuntimeError(
|
|
279
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
280
|
-
)
|
|
586
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
281
587
|
|
|
282
588
|
# Create callbacks for service initialization
|
|
283
589
|
async def create_indexes_callback(slug: str, manifest: "ManifestDict") -> None:
|
|
@@ -288,21 +594,15 @@ class MongoDBEngine:
|
|
|
288
594
|
if self._service_initializer:
|
|
289
595
|
await self._service_initializer.seed_initial_data(slug, initial_data)
|
|
290
596
|
|
|
291
|
-
async def initialize_memory_callback(
|
|
292
|
-
slug: str, memory_config: Dict[str, Any]
|
|
293
|
-
) -> None:
|
|
597
|
+
async def initialize_memory_callback(slug: str, memory_config: Dict[str, Any]) -> None:
|
|
294
598
|
if self._service_initializer:
|
|
295
|
-
await self._service_initializer.initialize_memory_service(
|
|
296
|
-
slug, memory_config
|
|
297
|
-
)
|
|
599
|
+
await self._service_initializer.initialize_memory_service(slug, memory_config)
|
|
298
600
|
|
|
299
601
|
async def register_websockets_callback(
|
|
300
602
|
slug: str, websockets_config: Dict[str, Any]
|
|
301
603
|
) -> None:
|
|
302
604
|
if self._service_initializer:
|
|
303
|
-
await self._service_initializer.register_websockets(
|
|
304
|
-
slug, websockets_config
|
|
305
|
-
)
|
|
605
|
+
await self._service_initializer.register_websockets(slug, websockets_config)
|
|
306
606
|
|
|
307
607
|
async def setup_observability_callback(
|
|
308
608
|
slug: str,
|
|
@@ -314,7 +614,8 @@ class MongoDBEngine:
|
|
|
314
614
|
slug, manifest, observability_config
|
|
315
615
|
)
|
|
316
616
|
|
|
317
|
-
|
|
617
|
+
# Register app first (this validates and stores the manifest)
|
|
618
|
+
result = await self._app_registration_manager.register_app(
|
|
318
619
|
manifest=manifest,
|
|
319
620
|
create_indexes_callback=create_indexes_callback if create_indexes else None,
|
|
320
621
|
seed_data_callback=seed_data_callback,
|
|
@@ -323,6 +624,33 @@ class MongoDBEngine:
|
|
|
323
624
|
setup_observability_callback=setup_observability_callback,
|
|
324
625
|
)
|
|
325
626
|
|
|
627
|
+
# Extract and store data_access configuration AFTER registration
|
|
628
|
+
slug = manifest.get("slug")
|
|
629
|
+
if slug:
|
|
630
|
+
data_access = manifest.get("data_access", {})
|
|
631
|
+
read_scopes = data_access.get("read_scopes")
|
|
632
|
+
if read_scopes:
|
|
633
|
+
self._app_read_scopes[slug] = read_scopes
|
|
634
|
+
else:
|
|
635
|
+
# Default to app_slug if not specified
|
|
636
|
+
self._app_read_scopes[slug] = [slug]
|
|
637
|
+
|
|
638
|
+
# Generate and store app secret if secrets manager is available
|
|
639
|
+
if self._app_secrets_manager:
|
|
640
|
+
# Check if secret already exists (don't overwrite)
|
|
641
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
642
|
+
if not secret_exists:
|
|
643
|
+
app_secret = secrets.token_urlsafe(32)
|
|
644
|
+
await self._app_secrets_manager.store_app_secret(slug, app_secret)
|
|
645
|
+
logger.info(
|
|
646
|
+
f"Generated and stored encrypted secret for app '{slug}'. "
|
|
647
|
+
"Store this secret securely and provide it as app_token in get_scoped_db()."
|
|
648
|
+
)
|
|
649
|
+
# Note: In production, the secret should be retrieved via rotation API
|
|
650
|
+
# For now, we log it (in production, this should be handled differently)
|
|
651
|
+
|
|
652
|
+
return result
|
|
653
|
+
|
|
326
654
|
def get_websocket_config(self, slug: str) -> Optional[Dict[str, Any]]:
|
|
327
655
|
"""
|
|
328
656
|
Get WebSocket configuration for an app.
|
|
@@ -428,15 +756,9 @@ class MongoDBEngine:
|
|
|
428
756
|
# Include the router in the app
|
|
429
757
|
app.include_router(ws_router)
|
|
430
758
|
|
|
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
|
-
)
|
|
759
|
+
print(f"✅ Registered WebSocket route '{path}' for app '{slug}' using APIRouter")
|
|
760
|
+
print(f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}")
|
|
761
|
+
print(f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}")
|
|
440
762
|
print(f" Route path: {path}, Full route count: {len(app.routes)}")
|
|
441
763
|
contextual_logger.info(
|
|
442
764
|
f"✅ Registered WebSocket route '{path}' for app '{slug}' "
|
|
@@ -459,9 +781,7 @@ class MongoDBEngine:
|
|
|
459
781
|
"error": str(e),
|
|
460
782
|
},
|
|
461
783
|
)
|
|
462
|
-
print(
|
|
463
|
-
f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}"
|
|
464
|
-
)
|
|
784
|
+
print(f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}")
|
|
465
785
|
import traceback
|
|
466
786
|
|
|
467
787
|
traceback.print_exc()
|
|
@@ -483,9 +803,7 @@ class MongoDBEngine:
|
|
|
483
803
|
RuntimeError: If engine is not initialized.
|
|
484
804
|
"""
|
|
485
805
|
if not self._app_registration_manager:
|
|
486
|
-
raise RuntimeError(
|
|
487
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
488
|
-
)
|
|
806
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
489
807
|
|
|
490
808
|
return await self._app_registration_manager.reload_apps(
|
|
491
809
|
register_app_callback=self.register_app
|
|
@@ -502,9 +820,7 @@ class MongoDBEngine:
|
|
|
502
820
|
App manifest dict or None if not found
|
|
503
821
|
"""
|
|
504
822
|
if not self._app_registration_manager:
|
|
505
|
-
raise RuntimeError(
|
|
506
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
507
|
-
)
|
|
823
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
508
824
|
return self._app_registration_manager.get_app(slug)
|
|
509
825
|
|
|
510
826
|
async def get_manifest(self, slug: str) -> Optional["ManifestDict"]:
|
|
@@ -518,20 +834,9 @@ class MongoDBEngine:
|
|
|
518
834
|
App manifest dict or None if not found
|
|
519
835
|
"""
|
|
520
836
|
if not self._app_registration_manager:
|
|
521
|
-
raise RuntimeError(
|
|
522
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
523
|
-
)
|
|
837
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
524
838
|
return await self._app_registration_manager.get_manifest(slug)
|
|
525
839
|
|
|
526
|
-
def get_database(self) -> AsyncIOMotorDatabase:
|
|
527
|
-
"""
|
|
528
|
-
Get the MongoDB database instance.
|
|
529
|
-
|
|
530
|
-
Returns:
|
|
531
|
-
AsyncIOMotorDatabase instance
|
|
532
|
-
"""
|
|
533
|
-
return self.mongo_db
|
|
534
|
-
|
|
535
840
|
def get_memory_service(self, slug: str) -> Optional[Any]:
|
|
536
841
|
"""
|
|
537
842
|
Get Mem0 memory service for an app.
|
|
@@ -568,9 +873,7 @@ class MongoDBEngine:
|
|
|
568
873
|
RuntimeError: If engine is not initialized
|
|
569
874
|
"""
|
|
570
875
|
if not self._app_registration_manager:
|
|
571
|
-
raise RuntimeError(
|
|
572
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
573
|
-
)
|
|
876
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
574
877
|
return self._app_registration_manager._apps
|
|
575
878
|
|
|
576
879
|
def list_apps(self) -> List[str]:
|
|
@@ -581,9 +884,7 @@ class MongoDBEngine:
|
|
|
581
884
|
List of app slugs
|
|
582
885
|
"""
|
|
583
886
|
if not self._app_registration_manager:
|
|
584
|
-
raise RuntimeError(
|
|
585
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
586
|
-
)
|
|
887
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
587
888
|
return self._app_registration_manager.list_apps()
|
|
588
889
|
|
|
589
890
|
async def shutdown(self) -> None:
|
|
@@ -693,20 +994,12 @@ class MongoDBEngine:
|
|
|
693
994
|
# This follows MongoDB best practice: monitor the actual client
|
|
694
995
|
# being used
|
|
695
996
|
async def get_metrics():
|
|
696
|
-
metrics = await get_pool_metrics(
|
|
697
|
-
self._connection_manager.mongo_client
|
|
698
|
-
)
|
|
997
|
+
metrics = await get_pool_metrics(self._connection_manager.mongo_client)
|
|
699
998
|
# Add MongoDBEngine's pool configuration if not already in metrics
|
|
700
999
|
if metrics.get("status") == "connected":
|
|
701
|
-
if (
|
|
702
|
-
"max_pool_size" not in metrics
|
|
703
|
-
or metrics.get("max_pool_size") is None
|
|
704
|
-
):
|
|
1000
|
+
if "max_pool_size" not in metrics or metrics.get("max_pool_size") is None:
|
|
705
1001
|
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
|
-
):
|
|
1002
|
+
if "min_pool_size" not in metrics or metrics.get("min_pool_size") is None:
|
|
710
1003
|
metrics["min_pool_size"] = self.min_pool_size
|
|
711
1004
|
return metrics
|
|
712
1005
|
|
|
@@ -719,8 +1012,10 @@ class MongoDBEngine:
|
|
|
719
1012
|
usage = details.get("pool_usage_percent", 0)
|
|
720
1013
|
if usage <= 90 and details.get("status") == "connected":
|
|
721
1014
|
# Not critical, downgrade to degraded
|
|
722
|
-
from ..observability.health import (
|
|
723
|
-
|
|
1015
|
+
from ..observability.health import (
|
|
1016
|
+
HealthCheckResult,
|
|
1017
|
+
HealthStatus,
|
|
1018
|
+
)
|
|
724
1019
|
|
|
725
1020
|
return HealthCheckResult(
|
|
726
1021
|
name=result.name,
|
|
@@ -747,3 +1042,371 @@ class MongoDBEngine:
|
|
|
747
1042
|
|
|
748
1043
|
collector = get_metrics_collector()
|
|
749
1044
|
return collector.get_summary()
|
|
1045
|
+
|
|
1046
|
+
# =========================================================================
|
|
1047
|
+
# FastAPI Integration Methods
|
|
1048
|
+
# =========================================================================
|
|
1049
|
+
|
|
1050
|
+
def create_app(
|
|
1051
|
+
self,
|
|
1052
|
+
slug: str,
|
|
1053
|
+
manifest: Path,
|
|
1054
|
+
title: Optional[str] = None,
|
|
1055
|
+
**fastapi_kwargs: Any,
|
|
1056
|
+
) -> "FastAPI":
|
|
1057
|
+
"""
|
|
1058
|
+
Create a FastAPI application with proper lifespan management.
|
|
1059
|
+
|
|
1060
|
+
This method creates a FastAPI app that:
|
|
1061
|
+
1. Initializes the engine on startup
|
|
1062
|
+
2. Loads and registers the manifest
|
|
1063
|
+
3. Auto-detects multi-site mode from manifest
|
|
1064
|
+
4. Auto-configures auth based on manifest auth.mode:
|
|
1065
|
+
- "app" (default): Per-app token authentication
|
|
1066
|
+
- "shared": Shared user pool with SSO, auto-adds SharedAuthMiddleware
|
|
1067
|
+
5. Auto-retrieves app tokens (for "app" mode)
|
|
1068
|
+
6. Shuts down the engine on shutdown
|
|
1069
|
+
|
|
1070
|
+
Args:
|
|
1071
|
+
slug: Application slug (must match manifest slug)
|
|
1072
|
+
manifest: Path to manifest.json file
|
|
1073
|
+
title: FastAPI app title. Defaults to app name from manifest
|
|
1074
|
+
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1075
|
+
|
|
1076
|
+
Returns:
|
|
1077
|
+
Configured FastAPI application
|
|
1078
|
+
|
|
1079
|
+
Example:
|
|
1080
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
1081
|
+
app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
|
|
1082
|
+
|
|
1083
|
+
@app.get("/")
|
|
1084
|
+
async def index():
|
|
1085
|
+
db = engine.get_scoped_db("my_app")
|
|
1086
|
+
return {"status": "ok"}
|
|
1087
|
+
|
|
1088
|
+
Auth Modes (configured in manifest.json):
|
|
1089
|
+
# Per-app auth (default)
|
|
1090
|
+
{"auth": {"mode": "app"}}
|
|
1091
|
+
|
|
1092
|
+
# Shared user pool with SSO
|
|
1093
|
+
{"auth": {"mode": "shared", "roles": ["viewer", "editor", "admin"],
|
|
1094
|
+
"require_role": "viewer", "public_routes": ["/health"]}}
|
|
1095
|
+
"""
|
|
1096
|
+
import json
|
|
1097
|
+
|
|
1098
|
+
from fastapi import FastAPI
|
|
1099
|
+
|
|
1100
|
+
engine = self
|
|
1101
|
+
manifest_path = Path(manifest)
|
|
1102
|
+
|
|
1103
|
+
# Pre-load manifest synchronously to detect auth mode BEFORE creating app
|
|
1104
|
+
# This allows us to add middleware at app creation time (before startup)
|
|
1105
|
+
with open(manifest_path) as f:
|
|
1106
|
+
pre_manifest = json.load(f)
|
|
1107
|
+
|
|
1108
|
+
# Extract auth configuration
|
|
1109
|
+
auth_config = pre_manifest.get("auth", {})
|
|
1110
|
+
auth_mode = auth_config.get("mode", "app")
|
|
1111
|
+
|
|
1112
|
+
# Determine title from pre-loaded manifest or slug
|
|
1113
|
+
app_title = title or pre_manifest.get("name", slug)
|
|
1114
|
+
|
|
1115
|
+
# State that will be populated during initialization
|
|
1116
|
+
app_manifest: Dict[str, Any] = {}
|
|
1117
|
+
is_multi_site = False
|
|
1118
|
+
|
|
1119
|
+
@asynccontextmanager
|
|
1120
|
+
async def lifespan(app: FastAPI):
|
|
1121
|
+
"""Lifespan context manager for initialization and cleanup."""
|
|
1122
|
+
nonlocal app_manifest, is_multi_site
|
|
1123
|
+
|
|
1124
|
+
# Initialize engine
|
|
1125
|
+
await engine.initialize()
|
|
1126
|
+
|
|
1127
|
+
# Load and register manifest
|
|
1128
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
1129
|
+
await engine.register_app(app_manifest)
|
|
1130
|
+
|
|
1131
|
+
# Auto-detect multi-site mode from manifest
|
|
1132
|
+
data_access = app_manifest.get("data_access", {})
|
|
1133
|
+
read_scopes = data_access.get("read_scopes", [slug])
|
|
1134
|
+
cross_app_policy = data_access.get("cross_app_policy", "none")
|
|
1135
|
+
|
|
1136
|
+
# Multi-site if: cross_app_policy is "explicit" OR read_scopes has multiple apps
|
|
1137
|
+
is_multi_site = cross_app_policy == "explicit" or (
|
|
1138
|
+
len(read_scopes) > 1 and read_scopes != [slug]
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
if is_multi_site:
|
|
1142
|
+
logger.info(
|
|
1143
|
+
f"Multi-site mode detected for '{slug}': "
|
|
1144
|
+
f"read_scopes={read_scopes}, cross_app_policy={cross_app_policy}"
|
|
1145
|
+
)
|
|
1146
|
+
else:
|
|
1147
|
+
logger.info(f"Single-app mode for '{slug}'")
|
|
1148
|
+
|
|
1149
|
+
# Handle auth based on mode
|
|
1150
|
+
if auth_mode == "shared":
|
|
1151
|
+
logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
|
|
1152
|
+
# Initialize shared user pool and set on app.state
|
|
1153
|
+
# Middleware was already added at app creation time (lazy version)
|
|
1154
|
+
await engine._initialize_shared_user_pool(app)
|
|
1155
|
+
else:
|
|
1156
|
+
logger.info(f"Per-app auth mode for '{slug}'")
|
|
1157
|
+
# Auto-retrieve app token for "app" mode
|
|
1158
|
+
await engine.auto_retrieve_app_token(slug)
|
|
1159
|
+
|
|
1160
|
+
# Expose engine state on app.state
|
|
1161
|
+
app.state.engine = engine
|
|
1162
|
+
app.state.app_slug = slug
|
|
1163
|
+
app.state.manifest = app_manifest
|
|
1164
|
+
app.state.is_multi_site = is_multi_site
|
|
1165
|
+
app.state.auth_mode = auth_mode
|
|
1166
|
+
app.state.ray_actor = engine.ray_actor
|
|
1167
|
+
|
|
1168
|
+
yield
|
|
1169
|
+
|
|
1170
|
+
await engine.shutdown()
|
|
1171
|
+
|
|
1172
|
+
# Create FastAPI app
|
|
1173
|
+
app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
|
|
1174
|
+
|
|
1175
|
+
# Add rate limiting middleware FIRST (outermost layer)
|
|
1176
|
+
# This ensures rate limiting happens before auth validation
|
|
1177
|
+
rate_limits_config = auth_config.get("rate_limits", {})
|
|
1178
|
+
if rate_limits_config or auth_mode == "shared":
|
|
1179
|
+
from ..auth.rate_limiter import create_rate_limit_middleware
|
|
1180
|
+
|
|
1181
|
+
rate_limit_middleware = create_rate_limit_middleware(
|
|
1182
|
+
manifest_auth=auth_config,
|
|
1183
|
+
)
|
|
1184
|
+
app.add_middleware(rate_limit_middleware)
|
|
1185
|
+
logger.info(
|
|
1186
|
+
f"AuthRateLimitMiddleware added for '{slug}' "
|
|
1187
|
+
f"(endpoints: {list(rate_limits_config.keys()) or 'defaults'})"
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
# Add shared auth middleware (after rate limiting)
|
|
1191
|
+
# Uses lazy version that reads user_pool from app.state
|
|
1192
|
+
if auth_mode == "shared":
|
|
1193
|
+
from ..auth.shared_middleware import create_shared_auth_middleware_lazy
|
|
1194
|
+
|
|
1195
|
+
middleware_class = create_shared_auth_middleware_lazy(
|
|
1196
|
+
app_slug=slug,
|
|
1197
|
+
manifest_auth=auth_config,
|
|
1198
|
+
)
|
|
1199
|
+
app.add_middleware(middleware_class)
|
|
1200
|
+
logger.info(
|
|
1201
|
+
f"LazySharedAuthMiddleware added for '{slug}' "
|
|
1202
|
+
f"(require_role={auth_config.get('require_role')})"
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
# Add CSRF middleware (after auth - auto-enabled for shared mode)
|
|
1206
|
+
# CSRF protection is enabled by default for shared auth mode
|
|
1207
|
+
csrf_config = auth_config.get("csrf_protection", True if auth_mode == "shared" else False)
|
|
1208
|
+
if csrf_config:
|
|
1209
|
+
from ..auth.csrf import create_csrf_middleware
|
|
1210
|
+
|
|
1211
|
+
csrf_middleware = create_csrf_middleware(
|
|
1212
|
+
manifest_auth=auth_config,
|
|
1213
|
+
)
|
|
1214
|
+
app.add_middleware(csrf_middleware)
|
|
1215
|
+
logger.info(f"CSRFMiddleware added for '{slug}'")
|
|
1216
|
+
|
|
1217
|
+
# Add security middleware (HSTS, headers)
|
|
1218
|
+
security_config = auth_config.get("security", {})
|
|
1219
|
+
hsts_config = security_config.get("hsts", {})
|
|
1220
|
+
if hsts_config.get("enabled", True) or auth_mode == "shared":
|
|
1221
|
+
from ..auth.middleware import SecurityMiddleware
|
|
1222
|
+
|
|
1223
|
+
app.add_middleware(
|
|
1224
|
+
SecurityMiddleware,
|
|
1225
|
+
require_https=False, # HSTS handles this in production
|
|
1226
|
+
csrf_protection=False, # Handled by CSRFMiddleware above
|
|
1227
|
+
security_headers=True,
|
|
1228
|
+
hsts_config=hsts_config,
|
|
1229
|
+
)
|
|
1230
|
+
logger.info(f"SecurityMiddleware added for '{slug}'")
|
|
1231
|
+
|
|
1232
|
+
logger.debug(f"FastAPI app created for '{slug}'")
|
|
1233
|
+
|
|
1234
|
+
return app
|
|
1235
|
+
|
|
1236
|
+
async def _initialize_shared_user_pool(
|
|
1237
|
+
self,
|
|
1238
|
+
app: "FastAPI",
|
|
1239
|
+
) -> None:
|
|
1240
|
+
"""
|
|
1241
|
+
Initialize shared user pool, audit log, and set them on app.state.
|
|
1242
|
+
|
|
1243
|
+
Called during lifespan startup for apps using "shared" auth mode.
|
|
1244
|
+
The lazy middleware (added at app creation time) will read the
|
|
1245
|
+
user_pool from app.state at request time.
|
|
1246
|
+
|
|
1247
|
+
Security Features:
|
|
1248
|
+
- JWT secret required (fails fast if not configured)
|
|
1249
|
+
- allow_insecure_dev mode for local development only
|
|
1250
|
+
- Audit logging for compliance and forensics
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
app: FastAPI application instance
|
|
1254
|
+
"""
|
|
1255
|
+
from ..auth.audit import AuthAuditLog
|
|
1256
|
+
from ..auth.shared_users import SharedUserPool
|
|
1257
|
+
|
|
1258
|
+
# Determine if we're in development mode
|
|
1259
|
+
# Development = allow insecure auto-generated JWT secret
|
|
1260
|
+
is_dev = (
|
|
1261
|
+
os.getenv("MDB_ENGINE_ENV", "").lower() in ("dev", "development", "local")
|
|
1262
|
+
or os.getenv("ENVIRONMENT", "").lower() in ("dev", "development", "local")
|
|
1263
|
+
or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# Create or get shared user pool
|
|
1267
|
+
if not hasattr(self, "_shared_user_pool") or self._shared_user_pool is None:
|
|
1268
|
+
self._shared_user_pool = SharedUserPool(
|
|
1269
|
+
self._connection_manager.mongo_db,
|
|
1270
|
+
allow_insecure_dev=is_dev,
|
|
1271
|
+
)
|
|
1272
|
+
await self._shared_user_pool.ensure_indexes()
|
|
1273
|
+
logger.info("SharedUserPool initialized")
|
|
1274
|
+
|
|
1275
|
+
# Expose user pool on app.state for middleware to access
|
|
1276
|
+
app.state.user_pool = self._shared_user_pool
|
|
1277
|
+
|
|
1278
|
+
# Initialize audit logging if enabled
|
|
1279
|
+
auth_config = getattr(app.state, "manifest", {}).get("auth", {})
|
|
1280
|
+
audit_config = auth_config.get("audit", {})
|
|
1281
|
+
audit_enabled = audit_config.get("enabled", True) # Default: enabled for shared auth
|
|
1282
|
+
|
|
1283
|
+
if audit_enabled:
|
|
1284
|
+
retention_days = audit_config.get("retention_days", 90)
|
|
1285
|
+
if not hasattr(self, "_auth_audit_log") or self._auth_audit_log is None:
|
|
1286
|
+
self._auth_audit_log = AuthAuditLog(
|
|
1287
|
+
self._connection_manager.mongo_db,
|
|
1288
|
+
retention_days=retention_days,
|
|
1289
|
+
)
|
|
1290
|
+
await self._auth_audit_log.ensure_indexes()
|
|
1291
|
+
logger.info(f"AuthAuditLog initialized (retention: {retention_days} days)")
|
|
1292
|
+
|
|
1293
|
+
app.state.audit_log = self._auth_audit_log
|
|
1294
|
+
|
|
1295
|
+
logger.info("SharedUserPool and AuditLog attached to app.state")
|
|
1296
|
+
|
|
1297
|
+
def lifespan(
|
|
1298
|
+
self,
|
|
1299
|
+
slug: str,
|
|
1300
|
+
manifest: Path,
|
|
1301
|
+
) -> Callable:
|
|
1302
|
+
"""
|
|
1303
|
+
Create a lifespan context manager for use with FastAPI.
|
|
1304
|
+
|
|
1305
|
+
Use this when you want more control over FastAPI app creation
|
|
1306
|
+
but still want automatic engine lifecycle management.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
slug: Application slug
|
|
1310
|
+
manifest: Path to manifest.json file
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
Async context manager for FastAPI lifespan
|
|
1314
|
+
|
|
1315
|
+
Example:
|
|
1316
|
+
engine = MongoDBEngine(...)
|
|
1317
|
+
app = FastAPI(lifespan=engine.lifespan("my_app", Path("manifest.json")))
|
|
1318
|
+
"""
|
|
1319
|
+
engine = self
|
|
1320
|
+
manifest_path = Path(manifest)
|
|
1321
|
+
|
|
1322
|
+
@asynccontextmanager
|
|
1323
|
+
async def _lifespan(app: Any):
|
|
1324
|
+
"""Lifespan context manager."""
|
|
1325
|
+
# Initialize engine
|
|
1326
|
+
await engine.initialize()
|
|
1327
|
+
|
|
1328
|
+
# Load and register manifest
|
|
1329
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
1330
|
+
await engine.register_app(app_manifest)
|
|
1331
|
+
|
|
1332
|
+
# Auto-retrieve app token
|
|
1333
|
+
await engine.auto_retrieve_app_token(slug)
|
|
1334
|
+
|
|
1335
|
+
# Expose on app.state
|
|
1336
|
+
app.state.engine = engine
|
|
1337
|
+
app.state.app_slug = slug
|
|
1338
|
+
app.state.manifest = app_manifest
|
|
1339
|
+
|
|
1340
|
+
yield
|
|
1341
|
+
|
|
1342
|
+
await engine.shutdown()
|
|
1343
|
+
|
|
1344
|
+
return _lifespan
|
|
1345
|
+
|
|
1346
|
+
async def auto_retrieve_app_token(self, slug: str) -> Optional[str]:
|
|
1347
|
+
"""
|
|
1348
|
+
Auto-retrieve app token from environment or database.
|
|
1349
|
+
|
|
1350
|
+
Follows convention: {SLUG_UPPER}_SECRET environment variable.
|
|
1351
|
+
Falls back to database retrieval via secrets manager.
|
|
1352
|
+
|
|
1353
|
+
Args:
|
|
1354
|
+
slug: Application slug
|
|
1355
|
+
|
|
1356
|
+
Returns:
|
|
1357
|
+
App token if found, None otherwise
|
|
1358
|
+
|
|
1359
|
+
Example:
|
|
1360
|
+
# Set MY_APP_SECRET environment variable, or
|
|
1361
|
+
# let the engine retrieve from database
|
|
1362
|
+
token = await engine.auto_retrieve_app_token("my_app")
|
|
1363
|
+
"""
|
|
1364
|
+
# Check cache first
|
|
1365
|
+
if slug in self._app_token_cache:
|
|
1366
|
+
logger.debug(f"Using cached token for '{slug}'")
|
|
1367
|
+
return self._app_token_cache[slug]
|
|
1368
|
+
|
|
1369
|
+
# Try environment variable first (convention: {SLUG}_SECRET)
|
|
1370
|
+
env_var_name = f"{slug.upper().replace('-', '_')}_SECRET"
|
|
1371
|
+
token = os.getenv(env_var_name)
|
|
1372
|
+
|
|
1373
|
+
if token:
|
|
1374
|
+
logger.info(f"App token for '{slug}' loaded from {env_var_name}")
|
|
1375
|
+
self._app_token_cache[slug] = token
|
|
1376
|
+
return token
|
|
1377
|
+
|
|
1378
|
+
# Try to retrieve from database
|
|
1379
|
+
if self._app_secrets_manager:
|
|
1380
|
+
try:
|
|
1381
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
1382
|
+
if secret_exists:
|
|
1383
|
+
token = await self._app_secrets_manager.get_app_secret(slug)
|
|
1384
|
+
if token:
|
|
1385
|
+
logger.info(f"App token for '{slug}' retrieved from database")
|
|
1386
|
+
self._app_token_cache[slug] = token
|
|
1387
|
+
return token
|
|
1388
|
+
else:
|
|
1389
|
+
logger.debug(f"No stored secret found for '{slug}'")
|
|
1390
|
+
except PyMongoError as e:
|
|
1391
|
+
logger.warning(f"Error retrieving app token for '{slug}': {e}")
|
|
1392
|
+
|
|
1393
|
+
logger.debug(
|
|
1394
|
+
f"No app token found for '{slug}'. "
|
|
1395
|
+
f"Set {env_var_name} environment variable or register app to generate one."
|
|
1396
|
+
)
|
|
1397
|
+
return None
|
|
1398
|
+
|
|
1399
|
+
def get_app_token(self, slug: str) -> Optional[str]:
|
|
1400
|
+
"""
|
|
1401
|
+
Get cached app token for a slug.
|
|
1402
|
+
|
|
1403
|
+
Returns token from cache if available. Use auto_retrieve_app_token()
|
|
1404
|
+
to populate the cache first.
|
|
1405
|
+
|
|
1406
|
+
Args:
|
|
1407
|
+
slug: Application slug
|
|
1408
|
+
|
|
1409
|
+
Returns:
|
|
1410
|
+
Cached app token or None
|
|
1411
|
+
"""
|
|
1412
|
+
return self._app_token_cache.get(slug)
|