mdb-engine 0.1.6__py3-none-any.whl → 0.2.0__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 +104 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +648 -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 +264 -69
- 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 +47 -50
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +53 -80
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +18 -38
- mdb_engine/auth/provider.py +270 -171
- 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 +1057 -93
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +459 -150
- 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 +140 -17
- 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/dependencies.py +426 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +248 -0
- mdb_engine/di/providers.py +205 -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 +37 -154
- 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/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 +25 -60
- mdb_engine-0.2.0.dist-info/METADATA +313 -0
- mdb_engine-0.2.0.dist-info/RECORD +96 -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.2.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.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, Awaitable, 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,40 +210,97 @@ class MongoDBEngine:
|
|
|
126
210
|
get_scoped_db_fn=self.get_scoped_db,
|
|
127
211
|
)
|
|
128
212
|
|
|
213
|
+
# Initialize Ray if enabled
|
|
214
|
+
if self.enable_ray:
|
|
215
|
+
await self._initialize_ray()
|
|
216
|
+
|
|
217
|
+
async def _initialize_ray(self) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Initialize Ray support (only if enabled and available).
|
|
220
|
+
|
|
221
|
+
This is called automatically during initialize() if enable_ray=True.
|
|
222
|
+
Gracefully degrades if Ray is not installed.
|
|
223
|
+
"""
|
|
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")
|
|
247
|
+
|
|
248
|
+
@property
|
|
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
|
+
|
|
129
253
|
@property
|
|
130
254
|
def mongo_client(self) -> AsyncIOMotorClient:
|
|
131
255
|
"""
|
|
132
|
-
Get the MongoDB client.
|
|
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.
|
|
133
265
|
|
|
134
266
|
Returns:
|
|
135
267
|
AsyncIOMotorClient instance
|
|
136
268
|
|
|
137
269
|
Raises:
|
|
138
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!
|
|
139
278
|
"""
|
|
140
279
|
return self._connection_manager.mongo_client
|
|
141
280
|
|
|
142
281
|
@property
|
|
143
|
-
def
|
|
282
|
+
def _initialized(self) -> bool:
|
|
283
|
+
"""Check if engine is initialized (internal)."""
|
|
284
|
+
return self._connection_manager.initialized
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def initialized(self) -> bool:
|
|
144
288
|
"""
|
|
145
|
-
|
|
289
|
+
Check if engine is initialized.
|
|
146
290
|
|
|
147
291
|
Returns:
|
|
148
|
-
|
|
292
|
+
True if the engine has been initialized, False otherwise.
|
|
149
293
|
|
|
150
|
-
|
|
151
|
-
|
|
294
|
+
Example:
|
|
295
|
+
if engine.initialized:
|
|
296
|
+
db = engine.get_scoped_db("my_app")
|
|
152
297
|
"""
|
|
153
|
-
return self._connection_manager.mongo_db
|
|
154
|
-
|
|
155
|
-
@property
|
|
156
|
-
def _initialized(self) -> bool:
|
|
157
|
-
"""Check if engine is initialized."""
|
|
158
298
|
return self._connection_manager.initialized
|
|
159
299
|
|
|
160
300
|
def get_scoped_db(
|
|
161
301
|
self,
|
|
162
302
|
app_slug: str,
|
|
303
|
+
app_token: Optional[str] = None,
|
|
163
304
|
read_scopes: Optional[List[str]] = None,
|
|
164
305
|
write_scope: Optional[str] = None,
|
|
165
306
|
auto_index: bool = True,
|
|
@@ -174,8 +315,12 @@ class MongoDBEngine:
|
|
|
174
315
|
|
|
175
316
|
Args:
|
|
176
317
|
app_slug: App slug (used as default for both read and write scopes)
|
|
177
|
-
|
|
178
|
-
|
|
318
|
+
app_token: App secret token for authentication. Required if app
|
|
319
|
+
secrets manager is initialized. If None and app has stored secret,
|
|
320
|
+
will attempt migration (backward compatibility).
|
|
321
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
322
|
+
read_scopes or defaults to [app_slug]. Allows cross-app data access
|
|
323
|
+
when needed.
|
|
179
324
|
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
180
325
|
All documents inserted through this wrapper will have this as their
|
|
181
326
|
app_id.
|
|
@@ -187,27 +332,210 @@ class MongoDBEngine:
|
|
|
187
332
|
|
|
188
333
|
Raises:
|
|
189
334
|
RuntimeError: If engine is not initialized.
|
|
335
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
190
336
|
|
|
191
337
|
Example:
|
|
192
|
-
>>> db = engine.get_scoped_db("my_app")
|
|
338
|
+
>>> db = engine.get_scoped_db("my_app", app_token="secret-token")
|
|
193
339
|
>>> # All queries are automatically scoped to "my_app"
|
|
194
340
|
>>> doc = await db.my_collection.find_one({"name": "test"})
|
|
195
341
|
"""
|
|
196
342
|
if not self._initialized:
|
|
197
|
-
raise RuntimeError(
|
|
198
|
-
|
|
199
|
-
|
|
343
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
344
|
+
|
|
345
|
+
# Verify app token if secrets manager is available
|
|
346
|
+
# Token verification will happen lazily in ScopedMongoWrapper if called from async context
|
|
347
|
+
if self._app_secrets_manager:
|
|
348
|
+
if app_token is None:
|
|
349
|
+
# Check if app has stored secret (backward compatibility)
|
|
350
|
+
# Use sync wrapper that handles async context
|
|
351
|
+
has_secret = self._app_secrets_manager.app_secret_exists_sync(app_slug)
|
|
352
|
+
if has_secret:
|
|
353
|
+
# Log detailed info
|
|
354
|
+
logger.warning(f"App token required for '{app_slug}'")
|
|
355
|
+
# Generic error message
|
|
356
|
+
raise ValueError("App token required. Provide app_token parameter.")
|
|
357
|
+
# No stored secret - allow (backward compatibility for apps without secrets)
|
|
358
|
+
logger.debug(
|
|
359
|
+
f"App '{app_slug}' has no stored secret, "
|
|
360
|
+
f"allowing access (backward compatibility)"
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
# Try to verify synchronously if possible, otherwise pass to wrapper
|
|
364
|
+
# for lazy verification
|
|
365
|
+
import asyncio
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
# Check if we're in an async context
|
|
369
|
+
asyncio.get_running_loop()
|
|
370
|
+
# We're in async context - can't verify synchronously without blocking
|
|
371
|
+
# Pass token to wrapper for lazy verification on first database operation
|
|
372
|
+
logger.debug(
|
|
373
|
+
f"Token verification deferred to first database operation for '{app_slug}' "
|
|
374
|
+
f"(async context detected)"
|
|
375
|
+
)
|
|
376
|
+
except RuntimeError:
|
|
377
|
+
# No event loop - safe to use sync verification
|
|
378
|
+
is_valid = self._app_secrets_manager.verify_app_secret_sync(app_slug, app_token)
|
|
379
|
+
if not is_valid:
|
|
380
|
+
# Log detailed info with app_slug
|
|
381
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
382
|
+
# Generic error message (from None: unrelated to RuntimeError)
|
|
383
|
+
raise ValueError("Invalid app token") from None
|
|
384
|
+
|
|
385
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
386
|
+
if read_scopes is not None:
|
|
387
|
+
if not isinstance(read_scopes, list):
|
|
388
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
389
|
+
if len(read_scopes) == 0:
|
|
390
|
+
raise ValueError("read_scopes cannot be empty")
|
|
391
|
+
|
|
392
|
+
# Use manifest read_scopes if not provided
|
|
393
|
+
if read_scopes is None:
|
|
394
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
200
395
|
|
|
396
|
+
if write_scope is None:
|
|
397
|
+
write_scope = app_slug
|
|
398
|
+
|
|
399
|
+
# Validate requested read_scopes against manifest authorization
|
|
400
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
401
|
+
for scope in read_scopes:
|
|
402
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
403
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
404
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
405
|
+
if scope not in authorized_scopes:
|
|
406
|
+
logger.warning(
|
|
407
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
408
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
409
|
+
)
|
|
410
|
+
raise ValueError(
|
|
411
|
+
"App not authorized to read from requested scope. "
|
|
412
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
413
|
+
)
|
|
414
|
+
if not read_scopes:
|
|
415
|
+
raise ValueError("read_scopes cannot be empty")
|
|
416
|
+
for scope in read_scopes:
|
|
417
|
+
if not isinstance(scope, str) or not scope:
|
|
418
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
419
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
420
|
+
|
|
421
|
+
# Validate write_scope
|
|
422
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
423
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
424
|
+
|
|
425
|
+
return ScopedMongoWrapper(
|
|
426
|
+
real_db=self._connection_manager.mongo_db,
|
|
427
|
+
read_scopes=read_scopes,
|
|
428
|
+
write_scope=write_scope,
|
|
429
|
+
auto_index=auto_index,
|
|
430
|
+
app_slug=app_slug,
|
|
431
|
+
app_token=app_token,
|
|
432
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
async def get_scoped_db_async(
|
|
436
|
+
self,
|
|
437
|
+
app_slug: str,
|
|
438
|
+
app_token: Optional[str] = None,
|
|
439
|
+
read_scopes: Optional[List[str]] = None,
|
|
440
|
+
write_scope: Optional[str] = None,
|
|
441
|
+
auto_index: bool = True,
|
|
442
|
+
) -> ScopedMongoWrapper:
|
|
443
|
+
"""
|
|
444
|
+
Asynchronous version of get_scoped_db that properly verifies tokens.
|
|
445
|
+
|
|
446
|
+
This method is preferred in async contexts to ensure token verification
|
|
447
|
+
happens correctly.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
app_slug: App slug (used as default for both read and write scopes)
|
|
451
|
+
app_token: App secret token for authentication. Required if app
|
|
452
|
+
secrets manager is initialized.
|
|
453
|
+
read_scopes: List of app slugs to read from. If None, uses manifest
|
|
454
|
+
read_scopes or defaults to [app_slug].
|
|
455
|
+
write_scope: App slug to write to. If None, defaults to app_slug.
|
|
456
|
+
auto_index: Whether to enable automatic index creation.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
ScopedMongoWrapper instance configured with the specified scopes.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
RuntimeError: If engine is not initialized.
|
|
463
|
+
ValueError: If app_token is invalid or read_scopes are unauthorized.
|
|
464
|
+
"""
|
|
465
|
+
if not self._initialized:
|
|
466
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
467
|
+
|
|
468
|
+
# Verify app token if secrets manager is available
|
|
469
|
+
if self._app_secrets_manager:
|
|
470
|
+
if app_token is None:
|
|
471
|
+
# Check if app has stored secret
|
|
472
|
+
has_secret = await self._app_secrets_manager.app_secret_exists(app_slug)
|
|
473
|
+
if has_secret:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"App token required for '{app_slug}'. " "Provide app_token parameter."
|
|
476
|
+
)
|
|
477
|
+
# No stored secret - allow (backward compatibility)
|
|
478
|
+
logger.debug(
|
|
479
|
+
f"App '{app_slug}' has no stored secret, "
|
|
480
|
+
f"allowing access (backward compatibility)"
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
# Verify token asynchronously
|
|
484
|
+
is_valid = await self._app_secrets_manager.verify_app_secret(app_slug, app_token)
|
|
485
|
+
if not is_valid:
|
|
486
|
+
# Log detailed info with app_slug
|
|
487
|
+
logger.warning(f"Security: Invalid app token for '{app_slug}'")
|
|
488
|
+
# Generic error message
|
|
489
|
+
raise ValueError("Invalid app token")
|
|
490
|
+
|
|
491
|
+
# Validate read_scopes type FIRST (before authorization check)
|
|
492
|
+
if read_scopes is not None:
|
|
493
|
+
if not isinstance(read_scopes, list):
|
|
494
|
+
raise ValueError(f"read_scopes must be a list, got {type(read_scopes)}")
|
|
495
|
+
if len(read_scopes) == 0:
|
|
496
|
+
raise ValueError("read_scopes cannot be empty")
|
|
497
|
+
|
|
498
|
+
# Use manifest read_scopes if not provided
|
|
201
499
|
if read_scopes is None:
|
|
202
|
-
read_scopes = [app_slug]
|
|
500
|
+
read_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
501
|
+
|
|
203
502
|
if write_scope is None:
|
|
204
503
|
write_scope = app_slug
|
|
205
504
|
|
|
505
|
+
# Validate requested read_scopes against manifest authorization
|
|
506
|
+
authorized_scopes = self._app_read_scopes.get(app_slug, [app_slug])
|
|
507
|
+
for scope in read_scopes:
|
|
508
|
+
if not isinstance(scope, str) or len(scope) == 0:
|
|
509
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope!r}")
|
|
510
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
511
|
+
if scope not in authorized_scopes:
|
|
512
|
+
logger.warning(
|
|
513
|
+
f"App '{app_slug}' not authorized to read from '{scope}'. "
|
|
514
|
+
f"Authorized scopes: {authorized_scopes}"
|
|
515
|
+
)
|
|
516
|
+
raise ValueError(
|
|
517
|
+
"App not authorized to read from requested scope. "
|
|
518
|
+
"Update manifest data_access.read_scopes to grant access."
|
|
519
|
+
)
|
|
520
|
+
if not read_scopes:
|
|
521
|
+
raise ValueError("read_scopes cannot be empty")
|
|
522
|
+
for scope in read_scopes:
|
|
523
|
+
if not isinstance(scope, str) or not scope:
|
|
524
|
+
logger.warning(f"Invalid app slug in read_scopes: {scope}")
|
|
525
|
+
raise ValueError("Invalid app slug in read_scopes")
|
|
526
|
+
|
|
527
|
+
# Validate write_scope
|
|
528
|
+
if not isinstance(write_scope, str) or not write_scope:
|
|
529
|
+
raise ValueError(f"write_scope must be a non-empty string, got {write_scope}")
|
|
530
|
+
|
|
206
531
|
return ScopedMongoWrapper(
|
|
207
532
|
real_db=self._connection_manager.mongo_db,
|
|
208
533
|
read_scopes=read_scopes,
|
|
209
534
|
write_scope=write_scope,
|
|
210
535
|
auto_index=auto_index,
|
|
536
|
+
app_slug=app_slug,
|
|
537
|
+
app_token=app_token,
|
|
538
|
+
app_secrets_manager=self._app_secrets_manager,
|
|
211
539
|
)
|
|
212
540
|
|
|
213
541
|
async def validate_manifest(
|
|
@@ -227,9 +555,7 @@ class MongoDBEngine:
|
|
|
227
555
|
- error_paths: List of JSON paths with validation errors, None if valid
|
|
228
556
|
"""
|
|
229
557
|
if not self._app_registration_manager:
|
|
230
|
-
raise RuntimeError(
|
|
231
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
232
|
-
)
|
|
558
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
233
559
|
return await self._app_registration_manager.validate_manifest(manifest)
|
|
234
560
|
|
|
235
561
|
async def load_manifest(self, path: Path) -> "ManifestDict":
|
|
@@ -247,14 +573,10 @@ class MongoDBEngine:
|
|
|
247
573
|
ValueError: If validation fails
|
|
248
574
|
"""
|
|
249
575
|
if not self._app_registration_manager:
|
|
250
|
-
raise RuntimeError(
|
|
251
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
252
|
-
)
|
|
576
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
253
577
|
return await self._app_registration_manager.load_manifest(path)
|
|
254
578
|
|
|
255
|
-
async def register_app(
|
|
256
|
-
self, manifest: "ManifestDict", create_indexes: bool = True
|
|
257
|
-
) -> bool:
|
|
579
|
+
async def register_app(self, manifest: "ManifestDict", create_indexes: bool = True) -> bool:
|
|
258
580
|
"""
|
|
259
581
|
Register an app from its manifest.
|
|
260
582
|
|
|
@@ -275,9 +597,7 @@ class MongoDBEngine:
|
|
|
275
597
|
RuntimeError: If engine is not initialized.
|
|
276
598
|
"""
|
|
277
599
|
if not self._app_registration_manager:
|
|
278
|
-
raise RuntimeError(
|
|
279
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
280
|
-
)
|
|
600
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
281
601
|
|
|
282
602
|
# Create callbacks for service initialization
|
|
283
603
|
async def create_indexes_callback(slug: str, manifest: "ManifestDict") -> None:
|
|
@@ -288,21 +608,15 @@ class MongoDBEngine:
|
|
|
288
608
|
if self._service_initializer:
|
|
289
609
|
await self._service_initializer.seed_initial_data(slug, initial_data)
|
|
290
610
|
|
|
291
|
-
async def initialize_memory_callback(
|
|
292
|
-
slug: str, memory_config: Dict[str, Any]
|
|
293
|
-
) -> None:
|
|
611
|
+
async def initialize_memory_callback(slug: str, memory_config: Dict[str, Any]) -> None:
|
|
294
612
|
if self._service_initializer:
|
|
295
|
-
await self._service_initializer.initialize_memory_service(
|
|
296
|
-
slug, memory_config
|
|
297
|
-
)
|
|
613
|
+
await self._service_initializer.initialize_memory_service(slug, memory_config)
|
|
298
614
|
|
|
299
615
|
async def register_websockets_callback(
|
|
300
616
|
slug: str, websockets_config: Dict[str, Any]
|
|
301
617
|
) -> None:
|
|
302
618
|
if self._service_initializer:
|
|
303
|
-
await self._service_initializer.register_websockets(
|
|
304
|
-
slug, websockets_config
|
|
305
|
-
)
|
|
619
|
+
await self._service_initializer.register_websockets(slug, websockets_config)
|
|
306
620
|
|
|
307
621
|
async def setup_observability_callback(
|
|
308
622
|
slug: str,
|
|
@@ -314,7 +628,8 @@ class MongoDBEngine:
|
|
|
314
628
|
slug, manifest, observability_config
|
|
315
629
|
)
|
|
316
630
|
|
|
317
|
-
|
|
631
|
+
# Register app first (this validates and stores the manifest)
|
|
632
|
+
result = await self._app_registration_manager.register_app(
|
|
318
633
|
manifest=manifest,
|
|
319
634
|
create_indexes_callback=create_indexes_callback if create_indexes else None,
|
|
320
635
|
seed_data_callback=seed_data_callback,
|
|
@@ -323,6 +638,33 @@ class MongoDBEngine:
|
|
|
323
638
|
setup_observability_callback=setup_observability_callback,
|
|
324
639
|
)
|
|
325
640
|
|
|
641
|
+
# Extract and store data_access configuration AFTER registration
|
|
642
|
+
slug = manifest.get("slug")
|
|
643
|
+
if slug:
|
|
644
|
+
data_access = manifest.get("data_access", {})
|
|
645
|
+
read_scopes = data_access.get("read_scopes")
|
|
646
|
+
if read_scopes:
|
|
647
|
+
self._app_read_scopes[slug] = read_scopes
|
|
648
|
+
else:
|
|
649
|
+
# Default to app_slug if not specified
|
|
650
|
+
self._app_read_scopes[slug] = [slug]
|
|
651
|
+
|
|
652
|
+
# Generate and store app secret if secrets manager is available
|
|
653
|
+
if self._app_secrets_manager:
|
|
654
|
+
# Check if secret already exists (don't overwrite)
|
|
655
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
656
|
+
if not secret_exists:
|
|
657
|
+
app_secret = secrets.token_urlsafe(32)
|
|
658
|
+
await self._app_secrets_manager.store_app_secret(slug, app_secret)
|
|
659
|
+
logger.info(
|
|
660
|
+
f"Generated and stored encrypted secret for app '{slug}'. "
|
|
661
|
+
"Store this secret securely and provide it as app_token in get_scoped_db()."
|
|
662
|
+
)
|
|
663
|
+
# Note: In production, the secret should be retrieved via rotation API
|
|
664
|
+
# For now, we log it (in production, this should be handled differently)
|
|
665
|
+
|
|
666
|
+
return result
|
|
667
|
+
|
|
326
668
|
def get_websocket_config(self, slug: str) -> Optional[Dict[str, Any]]:
|
|
327
669
|
"""
|
|
328
670
|
Get WebSocket configuration for an app.
|
|
@@ -428,15 +770,9 @@ class MongoDBEngine:
|
|
|
428
770
|
# Include the router in the app
|
|
429
771
|
app.include_router(ws_router)
|
|
430
772
|
|
|
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
|
-
)
|
|
773
|
+
print(f"✅ Registered WebSocket route '{path}' for app '{slug}' using APIRouter")
|
|
774
|
+
print(f" Handler type: {type(handler).__name__}, Callable: {callable(handler)}")
|
|
775
|
+
print(f" Route name: {slug}_{endpoint_name}, Auth required: {require_auth}")
|
|
440
776
|
print(f" Route path: {path}, Full route count: {len(app.routes)}")
|
|
441
777
|
contextual_logger.info(
|
|
442
778
|
f"✅ Registered WebSocket route '{path}' for app '{slug}' "
|
|
@@ -459,9 +795,7 @@ class MongoDBEngine:
|
|
|
459
795
|
"error": str(e),
|
|
460
796
|
},
|
|
461
797
|
)
|
|
462
|
-
print(
|
|
463
|
-
f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}"
|
|
464
|
-
)
|
|
798
|
+
print(f"❌ Failed to register WebSocket route '{path}' for app '{slug}': {e}")
|
|
465
799
|
import traceback
|
|
466
800
|
|
|
467
801
|
traceback.print_exc()
|
|
@@ -483,9 +817,7 @@ class MongoDBEngine:
|
|
|
483
817
|
RuntimeError: If engine is not initialized.
|
|
484
818
|
"""
|
|
485
819
|
if not self._app_registration_manager:
|
|
486
|
-
raise RuntimeError(
|
|
487
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
488
|
-
)
|
|
820
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
489
821
|
|
|
490
822
|
return await self._app_registration_manager.reload_apps(
|
|
491
823
|
register_app_callback=self.register_app
|
|
@@ -502,9 +834,7 @@ class MongoDBEngine:
|
|
|
502
834
|
App manifest dict or None if not found
|
|
503
835
|
"""
|
|
504
836
|
if not self._app_registration_manager:
|
|
505
|
-
raise RuntimeError(
|
|
506
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
507
|
-
)
|
|
837
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
508
838
|
return self._app_registration_manager.get_app(slug)
|
|
509
839
|
|
|
510
840
|
async def get_manifest(self, slug: str) -> Optional["ManifestDict"]:
|
|
@@ -518,20 +848,9 @@ class MongoDBEngine:
|
|
|
518
848
|
App manifest dict or None if not found
|
|
519
849
|
"""
|
|
520
850
|
if not self._app_registration_manager:
|
|
521
|
-
raise RuntimeError(
|
|
522
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
523
|
-
)
|
|
851
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
524
852
|
return await self._app_registration_manager.get_manifest(slug)
|
|
525
853
|
|
|
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
854
|
def get_memory_service(self, slug: str) -> Optional[Any]:
|
|
536
855
|
"""
|
|
537
856
|
Get Mem0 memory service for an app.
|
|
@@ -556,6 +875,30 @@ class MongoDBEngine:
|
|
|
556
875
|
return self._service_initializer.get_memory_service(slug)
|
|
557
876
|
return None
|
|
558
877
|
|
|
878
|
+
def get_embedding_service(self, slug: str) -> Optional[Any]:
|
|
879
|
+
"""
|
|
880
|
+
Get EmbeddingService for an app.
|
|
881
|
+
|
|
882
|
+
Auto-detects OpenAI or AzureOpenAI from environment variables.
|
|
883
|
+
Uses embedding_config from manifest.json if available.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
slug: App slug
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
EmbeddingService instance if embedding is enabled for this app, None otherwise
|
|
890
|
+
|
|
891
|
+
Example:
|
|
892
|
+
```python
|
|
893
|
+
embedding_service = engine.get_embedding_service("my_app")
|
|
894
|
+
if embedding_service:
|
|
895
|
+
vectors = await embedding_service.embed_chunks(["Hello world"])
|
|
896
|
+
```
|
|
897
|
+
"""
|
|
898
|
+
from ..embeddings.dependencies import get_embedding_service_for_app
|
|
899
|
+
|
|
900
|
+
return get_embedding_service_for_app(slug, self)
|
|
901
|
+
|
|
559
902
|
@property
|
|
560
903
|
def _apps(self) -> Dict[str, Any]:
|
|
561
904
|
"""
|
|
@@ -568,9 +911,7 @@ class MongoDBEngine:
|
|
|
568
911
|
RuntimeError: If engine is not initialized
|
|
569
912
|
"""
|
|
570
913
|
if not self._app_registration_manager:
|
|
571
|
-
raise RuntimeError(
|
|
572
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
573
|
-
)
|
|
914
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
574
915
|
return self._app_registration_manager._apps
|
|
575
916
|
|
|
576
917
|
def list_apps(self) -> List[str]:
|
|
@@ -581,9 +922,7 @@ class MongoDBEngine:
|
|
|
581
922
|
List of app slugs
|
|
582
923
|
"""
|
|
583
924
|
if not self._app_registration_manager:
|
|
584
|
-
raise RuntimeError(
|
|
585
|
-
"MongoDBEngine not initialized. Call initialize() first."
|
|
586
|
-
)
|
|
925
|
+
raise RuntimeError("MongoDBEngine not initialized. Call initialize() first.")
|
|
587
926
|
return self._app_registration_manager.list_apps()
|
|
588
927
|
|
|
589
928
|
async def shutdown(self) -> None:
|
|
@@ -693,20 +1032,12 @@ class MongoDBEngine:
|
|
|
693
1032
|
# This follows MongoDB best practice: monitor the actual client
|
|
694
1033
|
# being used
|
|
695
1034
|
async def get_metrics():
|
|
696
|
-
metrics = await get_pool_metrics(
|
|
697
|
-
self._connection_manager.mongo_client
|
|
698
|
-
)
|
|
1035
|
+
metrics = await get_pool_metrics(self._connection_manager.mongo_client)
|
|
699
1036
|
# Add MongoDBEngine's pool configuration if not already in metrics
|
|
700
1037
|
if metrics.get("status") == "connected":
|
|
701
|
-
if (
|
|
702
|
-
"max_pool_size" not in metrics
|
|
703
|
-
or metrics.get("max_pool_size") is None
|
|
704
|
-
):
|
|
1038
|
+
if "max_pool_size" not in metrics or metrics.get("max_pool_size") is None:
|
|
705
1039
|
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
|
-
):
|
|
1040
|
+
if "min_pool_size" not in metrics or metrics.get("min_pool_size") is None:
|
|
710
1041
|
metrics["min_pool_size"] = self.min_pool_size
|
|
711
1042
|
return metrics
|
|
712
1043
|
|
|
@@ -719,8 +1050,10 @@ class MongoDBEngine:
|
|
|
719
1050
|
usage = details.get("pool_usage_percent", 0)
|
|
720
1051
|
if usage <= 90 and details.get("status") == "connected":
|
|
721
1052
|
# Not critical, downgrade to degraded
|
|
722
|
-
from ..observability.health import (
|
|
723
|
-
|
|
1053
|
+
from ..observability.health import (
|
|
1054
|
+
HealthCheckResult,
|
|
1055
|
+
HealthStatus,
|
|
1056
|
+
)
|
|
724
1057
|
|
|
725
1058
|
return HealthCheckResult(
|
|
726
1059
|
name=result.name,
|
|
@@ -747,3 +1080,634 @@ class MongoDBEngine:
|
|
|
747
1080
|
|
|
748
1081
|
collector = get_metrics_collector()
|
|
749
1082
|
return collector.get_summary()
|
|
1083
|
+
|
|
1084
|
+
# =========================================================================
|
|
1085
|
+
# FastAPI Integration Methods
|
|
1086
|
+
# =========================================================================
|
|
1087
|
+
|
|
1088
|
+
def create_app(
|
|
1089
|
+
self,
|
|
1090
|
+
slug: str,
|
|
1091
|
+
manifest: Path,
|
|
1092
|
+
title: Optional[str] = None,
|
|
1093
|
+
on_startup: Optional[
|
|
1094
|
+
Callable[["FastAPI", "MongoDBEngine", Dict[str, Any]], Awaitable[None]]
|
|
1095
|
+
] = None,
|
|
1096
|
+
on_shutdown: Optional[
|
|
1097
|
+
Callable[["FastAPI", "MongoDBEngine", Dict[str, Any]], Awaitable[None]]
|
|
1098
|
+
] = None,
|
|
1099
|
+
**fastapi_kwargs: Any,
|
|
1100
|
+
) -> "FastAPI":
|
|
1101
|
+
"""
|
|
1102
|
+
Create a FastAPI application with proper lifespan management.
|
|
1103
|
+
|
|
1104
|
+
This method creates a FastAPI app that:
|
|
1105
|
+
1. Initializes the engine on startup
|
|
1106
|
+
2. Loads and registers the manifest
|
|
1107
|
+
3. Auto-detects multi-site mode from manifest
|
|
1108
|
+
4. Auto-configures auth based on manifest auth.mode:
|
|
1109
|
+
- "app" (default): Per-app token authentication
|
|
1110
|
+
- "shared": Shared user pool with SSO, auto-adds SharedAuthMiddleware
|
|
1111
|
+
5. Auto-retrieves app tokens (for "app" mode)
|
|
1112
|
+
6. Calls on_startup callback (if provided)
|
|
1113
|
+
7. Shuts down the engine on shutdown (calls on_shutdown first if provided)
|
|
1114
|
+
|
|
1115
|
+
Args:
|
|
1116
|
+
slug: Application slug (must match manifest slug)
|
|
1117
|
+
manifest: Path to manifest.json file
|
|
1118
|
+
title: FastAPI app title. Defaults to app name from manifest
|
|
1119
|
+
on_startup: Optional async callback called after engine initialization.
|
|
1120
|
+
Signature: async def callback(app, engine, manifest) -> None
|
|
1121
|
+
on_shutdown: Optional async callback called before engine shutdown.
|
|
1122
|
+
Signature: async def callback(app, engine, manifest) -> None
|
|
1123
|
+
**fastapi_kwargs: Additional arguments passed to FastAPI()
|
|
1124
|
+
|
|
1125
|
+
Returns:
|
|
1126
|
+
Configured FastAPI application
|
|
1127
|
+
|
|
1128
|
+
Example:
|
|
1129
|
+
async def my_startup(app, engine, manifest):
|
|
1130
|
+
db = engine.get_scoped_db("my_app")
|
|
1131
|
+
await db.config.insert_one({"initialized": True})
|
|
1132
|
+
|
|
1133
|
+
engine = MongoDBEngine(mongo_uri=..., db_name=...)
|
|
1134
|
+
app = engine.create_app(
|
|
1135
|
+
slug="my_app",
|
|
1136
|
+
manifest=Path("manifest.json"),
|
|
1137
|
+
on_startup=my_startup,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
@app.get("/")
|
|
1141
|
+
async def index():
|
|
1142
|
+
db = engine.get_scoped_db("my_app")
|
|
1143
|
+
return {"status": "ok"}
|
|
1144
|
+
|
|
1145
|
+
Auth Modes (configured in manifest.json):
|
|
1146
|
+
# Per-app auth (default)
|
|
1147
|
+
{"auth": {"mode": "app"}}
|
|
1148
|
+
|
|
1149
|
+
# Shared user pool with SSO
|
|
1150
|
+
{"auth": {"mode": "shared", "roles": ["viewer", "editor", "admin"],
|
|
1151
|
+
"require_role": "viewer", "public_routes": ["/health"]}}
|
|
1152
|
+
"""
|
|
1153
|
+
import json
|
|
1154
|
+
|
|
1155
|
+
from fastapi import FastAPI
|
|
1156
|
+
|
|
1157
|
+
engine = self
|
|
1158
|
+
manifest_path = Path(manifest)
|
|
1159
|
+
|
|
1160
|
+
# Pre-load manifest synchronously to detect auth mode BEFORE creating app
|
|
1161
|
+
# This allows us to add middleware at app creation time (before startup)
|
|
1162
|
+
with open(manifest_path) as f:
|
|
1163
|
+
pre_manifest = json.load(f)
|
|
1164
|
+
|
|
1165
|
+
# Extract auth configuration
|
|
1166
|
+
auth_config = pre_manifest.get("auth", {})
|
|
1167
|
+
auth_mode = auth_config.get("mode", "app")
|
|
1168
|
+
|
|
1169
|
+
# Determine title from pre-loaded manifest or slug
|
|
1170
|
+
app_title = title or pre_manifest.get("name", slug)
|
|
1171
|
+
|
|
1172
|
+
# State that will be populated during initialization
|
|
1173
|
+
app_manifest: Dict[str, Any] = {}
|
|
1174
|
+
is_multi_site = False
|
|
1175
|
+
|
|
1176
|
+
@asynccontextmanager
|
|
1177
|
+
async def lifespan(app: FastAPI):
|
|
1178
|
+
"""Lifespan context manager for initialization and cleanup."""
|
|
1179
|
+
nonlocal app_manifest, is_multi_site
|
|
1180
|
+
|
|
1181
|
+
# Initialize engine
|
|
1182
|
+
await engine.initialize()
|
|
1183
|
+
|
|
1184
|
+
# Load and register manifest
|
|
1185
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
1186
|
+
await engine.register_app(app_manifest)
|
|
1187
|
+
|
|
1188
|
+
# Auto-detect multi-site mode from manifest
|
|
1189
|
+
data_access = app_manifest.get("data_access", {})
|
|
1190
|
+
read_scopes = data_access.get("read_scopes", [slug])
|
|
1191
|
+
cross_app_policy = data_access.get("cross_app_policy", "none")
|
|
1192
|
+
|
|
1193
|
+
# Multi-site if: cross_app_policy is "explicit" OR read_scopes has multiple apps
|
|
1194
|
+
is_multi_site = cross_app_policy == "explicit" or (
|
|
1195
|
+
len(read_scopes) > 1 and read_scopes != [slug]
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if is_multi_site:
|
|
1199
|
+
logger.info(
|
|
1200
|
+
f"Multi-site mode detected for '{slug}': "
|
|
1201
|
+
f"read_scopes={read_scopes}, cross_app_policy={cross_app_policy}"
|
|
1202
|
+
)
|
|
1203
|
+
else:
|
|
1204
|
+
logger.info(f"Single-app mode for '{slug}'")
|
|
1205
|
+
|
|
1206
|
+
# Handle auth based on mode
|
|
1207
|
+
if auth_mode == "shared":
|
|
1208
|
+
logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
|
|
1209
|
+
# Initialize shared user pool and set on app.state
|
|
1210
|
+
# Middleware was already added at app creation time (lazy version)
|
|
1211
|
+
await engine._initialize_shared_user_pool(app, app_manifest)
|
|
1212
|
+
else:
|
|
1213
|
+
logger.info(f"Per-app auth mode for '{slug}'")
|
|
1214
|
+
# Auto-retrieve app token for "app" mode
|
|
1215
|
+
await engine.auto_retrieve_app_token(slug)
|
|
1216
|
+
|
|
1217
|
+
# Auto-initialize authorization provider from manifest config
|
|
1218
|
+
try:
|
|
1219
|
+
logger.info(
|
|
1220
|
+
f"🔍 Checking auth config for '{slug}': "
|
|
1221
|
+
f"auth_config keys={list(auth_config.keys())}"
|
|
1222
|
+
)
|
|
1223
|
+
auth_policy = auth_config.get("policy", {})
|
|
1224
|
+
logger.info(f"🔍 Auth policy for '{slug}': {auth_policy}")
|
|
1225
|
+
authz_provider_type = auth_policy.get("provider")
|
|
1226
|
+
logger.info(f"🔍 Authz provider type for '{slug}': {authz_provider_type}")
|
|
1227
|
+
except (KeyError, AttributeError, TypeError) as e:
|
|
1228
|
+
logger.exception(f"❌ Error reading auth config for '{slug}': {e}")
|
|
1229
|
+
authz_provider_type = None
|
|
1230
|
+
|
|
1231
|
+
if authz_provider_type == "oso":
|
|
1232
|
+
# Initialize OSO Cloud provider
|
|
1233
|
+
try:
|
|
1234
|
+
from ..auth.oso_factory import initialize_oso_from_manifest
|
|
1235
|
+
|
|
1236
|
+
authz_provider = await initialize_oso_from_manifest(engine, slug, app_manifest)
|
|
1237
|
+
if authz_provider:
|
|
1238
|
+
app.state.authz_provider = authz_provider
|
|
1239
|
+
logger.info(f"✅ OSO Cloud provider auto-initialized for '{slug}'")
|
|
1240
|
+
else:
|
|
1241
|
+
logger.warning(
|
|
1242
|
+
f"⚠️ OSO provider not initialized for '{slug}' - "
|
|
1243
|
+
"check OSO_AUTH and OSO_URL environment variables"
|
|
1244
|
+
)
|
|
1245
|
+
except ImportError as e:
|
|
1246
|
+
logger.warning(
|
|
1247
|
+
f"⚠️ OSO Cloud SDK not available for '{slug}': {e}. "
|
|
1248
|
+
"Install with: pip install oso-cloud"
|
|
1249
|
+
)
|
|
1250
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1251
|
+
logger.exception(f"❌ Failed to initialize OSO provider for '{slug}': {e}")
|
|
1252
|
+
|
|
1253
|
+
elif authz_provider_type == "casbin":
|
|
1254
|
+
# Initialize Casbin provider
|
|
1255
|
+
logger.info(f"🔧 Initializing Casbin provider for '{slug}'...")
|
|
1256
|
+
try:
|
|
1257
|
+
from ..auth.casbin_factory import initialize_casbin_from_manifest
|
|
1258
|
+
|
|
1259
|
+
logger.debug(f"Calling initialize_casbin_from_manifest for '{slug}'")
|
|
1260
|
+
authz_provider = await initialize_casbin_from_manifest(
|
|
1261
|
+
engine, slug, app_manifest
|
|
1262
|
+
)
|
|
1263
|
+
logger.debug(
|
|
1264
|
+
f"initialize_casbin_from_manifest returned: {authz_provider is not None}"
|
|
1265
|
+
)
|
|
1266
|
+
if authz_provider:
|
|
1267
|
+
app.state.authz_provider = authz_provider
|
|
1268
|
+
logger.info(
|
|
1269
|
+
f"✅ Casbin provider auto-initialized for '{slug}' "
|
|
1270
|
+
f"and set on app.state"
|
|
1271
|
+
)
|
|
1272
|
+
logger.info(
|
|
1273
|
+
f"✅ Provider type: {type(authz_provider).__name__}, "
|
|
1274
|
+
f"initialized: {getattr(authz_provider, '_initialized', 'unknown')}"
|
|
1275
|
+
)
|
|
1276
|
+
# Verify it's actually set
|
|
1277
|
+
if hasattr(app.state, "authz_provider") and app.state.authz_provider:
|
|
1278
|
+
logger.info("✅ Verified: app.state.authz_provider is set and not None")
|
|
1279
|
+
else:
|
|
1280
|
+
logger.error(
|
|
1281
|
+
"❌ CRITICAL: app.state.authz_provider was set but is now "
|
|
1282
|
+
"None or missing!"
|
|
1283
|
+
)
|
|
1284
|
+
else:
|
|
1285
|
+
logger.error(
|
|
1286
|
+
f"❌ Casbin provider initialization returned None for '{slug}' - "
|
|
1287
|
+
f"check logs above for errors"
|
|
1288
|
+
)
|
|
1289
|
+
logger.error(f"❌ This means authorization will NOT work for '{slug}'")
|
|
1290
|
+
except ImportError as e:
|
|
1291
|
+
# ImportError is expected if Casbin is not installed
|
|
1292
|
+
logger.warning(
|
|
1293
|
+
f"❌ Casbin not available for '{slug}': {e}. "
|
|
1294
|
+
"Install with: pip install mdb-engine[casbin]"
|
|
1295
|
+
)
|
|
1296
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1297
|
+
logger.exception(f"❌ Failed to initialize Casbin provider for '{slug}': {e}")
|
|
1298
|
+
# Informational message, not exception logging
|
|
1299
|
+
logger.error( # noqa: TRY400
|
|
1300
|
+
f"❌ This means authorization will NOT work for '{slug}' - "
|
|
1301
|
+
f"app.state.authz_provider will remain None"
|
|
1302
|
+
)
|
|
1303
|
+
except (
|
|
1304
|
+
RuntimeError,
|
|
1305
|
+
ValueError,
|
|
1306
|
+
AttributeError,
|
|
1307
|
+
TypeError,
|
|
1308
|
+
ConnectionError,
|
|
1309
|
+
OSError,
|
|
1310
|
+
) as e:
|
|
1311
|
+
# Catch specific exceptions that might occur during initialization
|
|
1312
|
+
logger.exception(
|
|
1313
|
+
f"❌ Unexpected error initializing Casbin provider for '{slug}': {e}"
|
|
1314
|
+
)
|
|
1315
|
+
# Informational message, not exception logging
|
|
1316
|
+
logger.error( # noqa: TRY400
|
|
1317
|
+
f"❌ This means authorization will NOT work for '{slug}' - "
|
|
1318
|
+
f"app.state.authz_provider will remain None"
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
elif authz_provider_type is None and auth_policy:
|
|
1322
|
+
# Default to Casbin if provider not specified but auth.policy exists
|
|
1323
|
+
logger.info(
|
|
1324
|
+
f"⚠️ No provider specified in auth.policy for '{slug}', "
|
|
1325
|
+
f"defaulting to Casbin"
|
|
1326
|
+
)
|
|
1327
|
+
try:
|
|
1328
|
+
from ..auth.casbin_factory import initialize_casbin_from_manifest
|
|
1329
|
+
|
|
1330
|
+
authz_provider = await initialize_casbin_from_manifest(
|
|
1331
|
+
engine, slug, app_manifest
|
|
1332
|
+
)
|
|
1333
|
+
if authz_provider:
|
|
1334
|
+
app.state.authz_provider = authz_provider
|
|
1335
|
+
logger.info(f"✅ Casbin provider auto-initialized for '{slug}' (default)")
|
|
1336
|
+
else:
|
|
1337
|
+
logger.warning(
|
|
1338
|
+
f"⚠️ Casbin provider not initialized for '{slug}' "
|
|
1339
|
+
f"(default attempt failed)"
|
|
1340
|
+
)
|
|
1341
|
+
except ImportError as e:
|
|
1342
|
+
logger.warning(
|
|
1343
|
+
f"⚠️ Casbin not available for '{slug}': {e}. "
|
|
1344
|
+
"Install with: pip install mdb-engine[casbin]"
|
|
1345
|
+
)
|
|
1346
|
+
except (
|
|
1347
|
+
ValueError,
|
|
1348
|
+
TypeError,
|
|
1349
|
+
RuntimeError,
|
|
1350
|
+
AttributeError,
|
|
1351
|
+
KeyError,
|
|
1352
|
+
) as e:
|
|
1353
|
+
logger.exception(
|
|
1354
|
+
f"❌ Failed to initialize Casbin provider for '{slug}' (default): {e}"
|
|
1355
|
+
)
|
|
1356
|
+
elif authz_provider_type:
|
|
1357
|
+
logger.warning(
|
|
1358
|
+
f"⚠️ Unknown authz provider type '{authz_provider_type}' for '{slug}' - "
|
|
1359
|
+
f"skipping initialization"
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# Auto-seed demo users if configured in manifest
|
|
1363
|
+
users_config = auth_config.get("users", {})
|
|
1364
|
+
if users_config.get("enabled") and users_config.get("demo_users"):
|
|
1365
|
+
try:
|
|
1366
|
+
from ..auth import ensure_demo_users_exist
|
|
1367
|
+
|
|
1368
|
+
db = engine.get_scoped_db(slug)
|
|
1369
|
+
demo_users = await ensure_demo_users_exist(
|
|
1370
|
+
db=db,
|
|
1371
|
+
slug_id=slug,
|
|
1372
|
+
config=app_manifest,
|
|
1373
|
+
)
|
|
1374
|
+
if demo_users:
|
|
1375
|
+
logger.info(f"✅ Seeded {len(demo_users)} demo user(s) for '{slug}'")
|
|
1376
|
+
except (
|
|
1377
|
+
ImportError,
|
|
1378
|
+
ValueError,
|
|
1379
|
+
TypeError,
|
|
1380
|
+
RuntimeError,
|
|
1381
|
+
AttributeError,
|
|
1382
|
+
KeyError,
|
|
1383
|
+
) as e:
|
|
1384
|
+
logger.warning(f"⚠️ Failed to seed demo users for '{slug}': {e}")
|
|
1385
|
+
|
|
1386
|
+
# Expose engine state on app.state
|
|
1387
|
+
app.state.engine = engine
|
|
1388
|
+
app.state.app_slug = slug
|
|
1389
|
+
app.state.manifest = app_manifest
|
|
1390
|
+
app.state.is_multi_site = is_multi_site
|
|
1391
|
+
app.state.auth_mode = auth_mode
|
|
1392
|
+
app.state.ray_actor = engine.ray_actor
|
|
1393
|
+
|
|
1394
|
+
# Initialize DI container (if not already set)
|
|
1395
|
+
from ..di import Container
|
|
1396
|
+
|
|
1397
|
+
if not hasattr(app.state, "container") or app.state.container is None:
|
|
1398
|
+
app.state.container = Container()
|
|
1399
|
+
logger.debug(f"DI Container initialized for '{slug}'")
|
|
1400
|
+
|
|
1401
|
+
# Call on_startup callback if provided
|
|
1402
|
+
if on_startup:
|
|
1403
|
+
try:
|
|
1404
|
+
await on_startup(app, engine, app_manifest)
|
|
1405
|
+
logger.info(f"on_startup callback completed for '{slug}'")
|
|
1406
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1407
|
+
logger.exception(f"on_startup callback failed for '{slug}': {e}")
|
|
1408
|
+
raise
|
|
1409
|
+
|
|
1410
|
+
yield
|
|
1411
|
+
|
|
1412
|
+
# Call on_shutdown callback if provided
|
|
1413
|
+
if on_shutdown:
|
|
1414
|
+
try:
|
|
1415
|
+
await on_shutdown(app, engine, app_manifest)
|
|
1416
|
+
logger.info(f"on_shutdown callback completed for '{slug}'")
|
|
1417
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1418
|
+
logger.warning(f"on_shutdown callback failed for '{slug}': {e}")
|
|
1419
|
+
|
|
1420
|
+
await engine.shutdown()
|
|
1421
|
+
|
|
1422
|
+
# Create FastAPI app
|
|
1423
|
+
app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
|
|
1424
|
+
|
|
1425
|
+
# Add request scope middleware (innermost layer - runs first on request)
|
|
1426
|
+
# This sets up the DI request scope for each request
|
|
1427
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
1428
|
+
|
|
1429
|
+
from ..di import ScopeManager
|
|
1430
|
+
|
|
1431
|
+
class RequestScopeMiddleware(BaseHTTPMiddleware):
|
|
1432
|
+
"""Middleware that manages request-scoped DI instances."""
|
|
1433
|
+
|
|
1434
|
+
async def dispatch(self, request, call_next):
|
|
1435
|
+
ScopeManager.begin_request()
|
|
1436
|
+
try:
|
|
1437
|
+
response = await call_next(request)
|
|
1438
|
+
return response
|
|
1439
|
+
finally:
|
|
1440
|
+
ScopeManager.end_request()
|
|
1441
|
+
|
|
1442
|
+
app.add_middleware(RequestScopeMiddleware)
|
|
1443
|
+
logger.debug(f"RequestScopeMiddleware added for '{slug}'")
|
|
1444
|
+
|
|
1445
|
+
# Add rate limiting middleware FIRST (outermost layer)
|
|
1446
|
+
# This ensures rate limiting happens before auth validation
|
|
1447
|
+
rate_limits_config = auth_config.get("rate_limits", {})
|
|
1448
|
+
if rate_limits_config or auth_mode == "shared":
|
|
1449
|
+
from ..auth.rate_limiter import create_rate_limit_middleware
|
|
1450
|
+
|
|
1451
|
+
rate_limit_middleware = create_rate_limit_middleware(
|
|
1452
|
+
manifest_auth=auth_config,
|
|
1453
|
+
)
|
|
1454
|
+
app.add_middleware(rate_limit_middleware)
|
|
1455
|
+
logger.info(
|
|
1456
|
+
f"AuthRateLimitMiddleware added for '{slug}' "
|
|
1457
|
+
f"(endpoints: {list(rate_limits_config.keys()) or 'defaults'})"
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
# Add shared auth middleware (after rate limiting)
|
|
1461
|
+
# Uses lazy version that reads user_pool from app.state
|
|
1462
|
+
if auth_mode == "shared":
|
|
1463
|
+
from ..auth.shared_middleware import create_shared_auth_middleware_lazy
|
|
1464
|
+
|
|
1465
|
+
middleware_class = create_shared_auth_middleware_lazy(
|
|
1466
|
+
app_slug=slug,
|
|
1467
|
+
manifest_auth=auth_config,
|
|
1468
|
+
)
|
|
1469
|
+
app.add_middleware(middleware_class)
|
|
1470
|
+
logger.info(
|
|
1471
|
+
f"LazySharedAuthMiddleware added for '{slug}' "
|
|
1472
|
+
f"(require_role={auth_config.get('require_role')})"
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
# Add CSRF middleware (after auth - auto-enabled for shared mode)
|
|
1476
|
+
# CSRF protection is enabled by default for shared auth mode
|
|
1477
|
+
csrf_config = auth_config.get("csrf_protection", True if auth_mode == "shared" else False)
|
|
1478
|
+
if csrf_config:
|
|
1479
|
+
from ..auth.csrf import create_csrf_middleware
|
|
1480
|
+
|
|
1481
|
+
csrf_middleware = create_csrf_middleware(
|
|
1482
|
+
manifest_auth=auth_config,
|
|
1483
|
+
)
|
|
1484
|
+
app.add_middleware(csrf_middleware)
|
|
1485
|
+
logger.info(f"CSRFMiddleware added for '{slug}'")
|
|
1486
|
+
|
|
1487
|
+
# Add security middleware (HSTS, headers)
|
|
1488
|
+
security_config = auth_config.get("security", {})
|
|
1489
|
+
hsts_config = security_config.get("hsts", {})
|
|
1490
|
+
if hsts_config.get("enabled", True) or auth_mode == "shared":
|
|
1491
|
+
from ..auth.middleware import SecurityMiddleware
|
|
1492
|
+
|
|
1493
|
+
app.add_middleware(
|
|
1494
|
+
SecurityMiddleware,
|
|
1495
|
+
require_https=False, # HSTS handles this in production
|
|
1496
|
+
csrf_protection=False, # Handled by CSRFMiddleware above
|
|
1497
|
+
security_headers=True,
|
|
1498
|
+
hsts_config=hsts_config,
|
|
1499
|
+
)
|
|
1500
|
+
logger.info(f"SecurityMiddleware added for '{slug}'")
|
|
1501
|
+
|
|
1502
|
+
logger.debug(f"FastAPI app created for '{slug}'")
|
|
1503
|
+
|
|
1504
|
+
return app
|
|
1505
|
+
|
|
1506
|
+
async def _initialize_shared_user_pool(
|
|
1507
|
+
self,
|
|
1508
|
+
app: "FastAPI",
|
|
1509
|
+
manifest: Optional[Dict[str, Any]] = None,
|
|
1510
|
+
) -> None:
|
|
1511
|
+
"""
|
|
1512
|
+
Initialize shared user pool, audit log, and set them on app.state.
|
|
1513
|
+
|
|
1514
|
+
Called during lifespan startup for apps using "shared" auth mode.
|
|
1515
|
+
The lazy middleware (added at app creation time) will read the
|
|
1516
|
+
user_pool from app.state at request time.
|
|
1517
|
+
|
|
1518
|
+
Security Features:
|
|
1519
|
+
- JWT secret required (fails fast if not configured)
|
|
1520
|
+
- allow_insecure_dev mode for local development only
|
|
1521
|
+
- Audit logging for compliance and forensics
|
|
1522
|
+
|
|
1523
|
+
Args:
|
|
1524
|
+
app: FastAPI application instance
|
|
1525
|
+
manifest: Optional manifest dict for seeding demo users
|
|
1526
|
+
"""
|
|
1527
|
+
from ..auth.audit import AuthAuditLog
|
|
1528
|
+
from ..auth.shared_users import SharedUserPool
|
|
1529
|
+
|
|
1530
|
+
# Determine if we're in development mode
|
|
1531
|
+
# Development = allow insecure auto-generated JWT secret
|
|
1532
|
+
is_dev = (
|
|
1533
|
+
os.getenv("MDB_ENGINE_ENV", "").lower() in ("dev", "development", "local")
|
|
1534
|
+
or os.getenv("ENVIRONMENT", "").lower() in ("dev", "development", "local")
|
|
1535
|
+
or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
# Create or get shared user pool
|
|
1539
|
+
if not hasattr(self, "_shared_user_pool") or self._shared_user_pool is None:
|
|
1540
|
+
self._shared_user_pool = SharedUserPool(
|
|
1541
|
+
self._connection_manager.mongo_db,
|
|
1542
|
+
allow_insecure_dev=is_dev,
|
|
1543
|
+
)
|
|
1544
|
+
await self._shared_user_pool.ensure_indexes()
|
|
1545
|
+
logger.info("SharedUserPool initialized")
|
|
1546
|
+
|
|
1547
|
+
# Expose user pool on app.state for middleware to access
|
|
1548
|
+
app.state.user_pool = self._shared_user_pool
|
|
1549
|
+
|
|
1550
|
+
# Seed demo users to SharedUserPool if configured in manifest
|
|
1551
|
+
if manifest:
|
|
1552
|
+
auth_config = manifest.get("auth", {})
|
|
1553
|
+
users_config = auth_config.get("users", {})
|
|
1554
|
+
demo_users = users_config.get("demo_users", [])
|
|
1555
|
+
|
|
1556
|
+
if demo_users and users_config.get("demo_user_seed_strategy", "auto") != "disabled":
|
|
1557
|
+
for demo in demo_users:
|
|
1558
|
+
try:
|
|
1559
|
+
email = demo.get("email")
|
|
1560
|
+
password = demo.get("password")
|
|
1561
|
+
app_roles = demo.get("app_roles", {})
|
|
1562
|
+
|
|
1563
|
+
existing = await self._shared_user_pool.get_user_by_email(email)
|
|
1564
|
+
|
|
1565
|
+
if not existing:
|
|
1566
|
+
await self._shared_user_pool.create_user(
|
|
1567
|
+
email=email,
|
|
1568
|
+
password=password,
|
|
1569
|
+
app_roles=app_roles,
|
|
1570
|
+
)
|
|
1571
|
+
logger.info(f"✅ Created shared demo user: {email}")
|
|
1572
|
+
else:
|
|
1573
|
+
logger.debug(f"ℹ️ Shared demo user exists: {email}")
|
|
1574
|
+
except (ValueError, TypeError, RuntimeError, AttributeError, KeyError) as e:
|
|
1575
|
+
logger.warning(
|
|
1576
|
+
f"⚠️ Failed to create shared demo user {demo.get('email')}: {e}"
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
# Initialize audit logging if enabled
|
|
1580
|
+
auth_config = (manifest or {}).get("auth", {})
|
|
1581
|
+
audit_config = auth_config.get("audit", {})
|
|
1582
|
+
audit_enabled = audit_config.get("enabled", True) # Default: enabled for shared auth
|
|
1583
|
+
|
|
1584
|
+
if audit_enabled:
|
|
1585
|
+
retention_days = audit_config.get("retention_days", 90)
|
|
1586
|
+
if not hasattr(self, "_auth_audit_log") or self._auth_audit_log is None:
|
|
1587
|
+
self._auth_audit_log = AuthAuditLog(
|
|
1588
|
+
self._connection_manager.mongo_db,
|
|
1589
|
+
retention_days=retention_days,
|
|
1590
|
+
)
|
|
1591
|
+
await self._auth_audit_log.ensure_indexes()
|
|
1592
|
+
logger.info(f"AuthAuditLog initialized (retention: {retention_days} days)")
|
|
1593
|
+
|
|
1594
|
+
app.state.audit_log = self._auth_audit_log
|
|
1595
|
+
|
|
1596
|
+
logger.info("SharedUserPool and AuditLog attached to app.state")
|
|
1597
|
+
|
|
1598
|
+
def lifespan(
|
|
1599
|
+
self,
|
|
1600
|
+
slug: str,
|
|
1601
|
+
manifest: Path,
|
|
1602
|
+
) -> Callable:
|
|
1603
|
+
"""
|
|
1604
|
+
Create a lifespan context manager for use with FastAPI.
|
|
1605
|
+
|
|
1606
|
+
Use this when you want more control over FastAPI app creation
|
|
1607
|
+
but still want automatic engine lifecycle management.
|
|
1608
|
+
|
|
1609
|
+
Args:
|
|
1610
|
+
slug: Application slug
|
|
1611
|
+
manifest: Path to manifest.json file
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
Async context manager for FastAPI lifespan
|
|
1615
|
+
|
|
1616
|
+
Example:
|
|
1617
|
+
engine = MongoDBEngine(...)
|
|
1618
|
+
app = FastAPI(lifespan=engine.lifespan("my_app", Path("manifest.json")))
|
|
1619
|
+
"""
|
|
1620
|
+
engine = self
|
|
1621
|
+
manifest_path = Path(manifest)
|
|
1622
|
+
|
|
1623
|
+
@asynccontextmanager
|
|
1624
|
+
async def _lifespan(app: Any):
|
|
1625
|
+
"""Lifespan context manager."""
|
|
1626
|
+
# Initialize engine
|
|
1627
|
+
await engine.initialize()
|
|
1628
|
+
|
|
1629
|
+
# Load and register manifest
|
|
1630
|
+
app_manifest = await engine.load_manifest(manifest_path)
|
|
1631
|
+
await engine.register_app(app_manifest)
|
|
1632
|
+
|
|
1633
|
+
# Auto-retrieve app token
|
|
1634
|
+
await engine.auto_retrieve_app_token(slug)
|
|
1635
|
+
|
|
1636
|
+
# Expose on app.state
|
|
1637
|
+
app.state.engine = engine
|
|
1638
|
+
app.state.app_slug = slug
|
|
1639
|
+
app.state.manifest = app_manifest
|
|
1640
|
+
|
|
1641
|
+
yield
|
|
1642
|
+
|
|
1643
|
+
await engine.shutdown()
|
|
1644
|
+
|
|
1645
|
+
return _lifespan
|
|
1646
|
+
|
|
1647
|
+
async def auto_retrieve_app_token(self, slug: str) -> Optional[str]:
|
|
1648
|
+
"""
|
|
1649
|
+
Auto-retrieve app token from environment or database.
|
|
1650
|
+
|
|
1651
|
+
Follows convention: {SLUG_UPPER}_SECRET environment variable.
|
|
1652
|
+
Falls back to database retrieval via secrets manager.
|
|
1653
|
+
|
|
1654
|
+
Args:
|
|
1655
|
+
slug: Application slug
|
|
1656
|
+
|
|
1657
|
+
Returns:
|
|
1658
|
+
App token if found, None otherwise
|
|
1659
|
+
|
|
1660
|
+
Example:
|
|
1661
|
+
# Set MY_APP_SECRET environment variable, or
|
|
1662
|
+
# let the engine retrieve from database
|
|
1663
|
+
token = await engine.auto_retrieve_app_token("my_app")
|
|
1664
|
+
"""
|
|
1665
|
+
# Check cache first
|
|
1666
|
+
if slug in self._app_token_cache:
|
|
1667
|
+
logger.debug(f"Using cached token for '{slug}'")
|
|
1668
|
+
return self._app_token_cache[slug]
|
|
1669
|
+
|
|
1670
|
+
# Try environment variable first (convention: {SLUG}_SECRET)
|
|
1671
|
+
env_var_name = f"{slug.upper().replace('-', '_')}_SECRET"
|
|
1672
|
+
token = os.getenv(env_var_name)
|
|
1673
|
+
|
|
1674
|
+
if token:
|
|
1675
|
+
logger.info(f"App token for '{slug}' loaded from {env_var_name}")
|
|
1676
|
+
self._app_token_cache[slug] = token
|
|
1677
|
+
return token
|
|
1678
|
+
|
|
1679
|
+
# Try to retrieve from database
|
|
1680
|
+
if self._app_secrets_manager:
|
|
1681
|
+
try:
|
|
1682
|
+
secret_exists = await self._app_secrets_manager.app_secret_exists(slug)
|
|
1683
|
+
if secret_exists:
|
|
1684
|
+
token = await self._app_secrets_manager.get_app_secret(slug)
|
|
1685
|
+
if token:
|
|
1686
|
+
logger.info(f"App token for '{slug}' retrieved from database")
|
|
1687
|
+
self._app_token_cache[slug] = token
|
|
1688
|
+
return token
|
|
1689
|
+
else:
|
|
1690
|
+
logger.debug(f"No stored secret found for '{slug}'")
|
|
1691
|
+
except PyMongoError as e:
|
|
1692
|
+
logger.warning(f"Error retrieving app token for '{slug}': {e}")
|
|
1693
|
+
|
|
1694
|
+
logger.debug(
|
|
1695
|
+
f"No app token found for '{slug}'. "
|
|
1696
|
+
f"Set {env_var_name} environment variable or register app to generate one."
|
|
1697
|
+
)
|
|
1698
|
+
return None
|
|
1699
|
+
|
|
1700
|
+
def get_app_token(self, slug: str) -> Optional[str]:
|
|
1701
|
+
"""
|
|
1702
|
+
Get cached app token for a slug.
|
|
1703
|
+
|
|
1704
|
+
Returns token from cache if available. Use auto_retrieve_app_token()
|
|
1705
|
+
to populate the cache first.
|
|
1706
|
+
|
|
1707
|
+
Args:
|
|
1708
|
+
slug: Application slug
|
|
1709
|
+
|
|
1710
|
+
Returns:
|
|
1711
|
+
Cached app token or None
|
|
1712
|
+
"""
|
|
1713
|
+
return self._app_token_cache.get(slug)
|