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.
Files changed (87) hide show
  1. mdb_engine/__init__.py +104 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +648 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +264 -69
  8. mdb_engine/auth/config_helpers.py +7 -6
  9. mdb_engine/auth/cookie_utils.py +3 -7
  10. mdb_engine/auth/csrf.py +373 -0
  11. mdb_engine/auth/decorators.py +3 -10
  12. mdb_engine/auth/dependencies.py +47 -50
  13. mdb_engine/auth/helpers.py +3 -3
  14. mdb_engine/auth/integration.py +53 -80
  15. mdb_engine/auth/jwt.py +2 -6
  16. mdb_engine/auth/middleware.py +77 -34
  17. mdb_engine/auth/oso_factory.py +18 -38
  18. mdb_engine/auth/provider.py +270 -171
  19. mdb_engine/auth/rate_limiter.py +504 -0
  20. mdb_engine/auth/restrictions.py +8 -24
  21. mdb_engine/auth/session_manager.py +14 -29
  22. mdb_engine/auth/shared_middleware.py +600 -0
  23. mdb_engine/auth/shared_users.py +759 -0
  24. mdb_engine/auth/token_store.py +14 -28
  25. mdb_engine/auth/users.py +54 -113
  26. mdb_engine/auth/utils.py +213 -15
  27. mdb_engine/cli/commands/generate.py +545 -9
  28. mdb_engine/cli/commands/validate.py +3 -7
  29. mdb_engine/cli/utils.py +3 -3
  30. mdb_engine/config.py +7 -21
  31. mdb_engine/constants.py +65 -0
  32. mdb_engine/core/README.md +117 -6
  33. mdb_engine/core/__init__.py +39 -7
  34. mdb_engine/core/app_registration.py +22 -41
  35. mdb_engine/core/app_secrets.py +290 -0
  36. mdb_engine/core/connection.py +18 -9
  37. mdb_engine/core/encryption.py +223 -0
  38. mdb_engine/core/engine.py +1057 -93
  39. mdb_engine/core/index_management.py +12 -16
  40. mdb_engine/core/manifest.py +459 -150
  41. mdb_engine/core/ray_integration.py +435 -0
  42. mdb_engine/core/seeding.py +10 -18
  43. mdb_engine/core/service_initialization.py +12 -23
  44. mdb_engine/core/types.py +2 -5
  45. mdb_engine/database/README.md +140 -17
  46. mdb_engine/database/__init__.py +17 -6
  47. mdb_engine/database/abstraction.py +25 -37
  48. mdb_engine/database/connection.py +11 -18
  49. mdb_engine/database/query_validator.py +367 -0
  50. mdb_engine/database/resource_limiter.py +204 -0
  51. mdb_engine/database/scoped_wrapper.py +713 -196
  52. mdb_engine/dependencies.py +426 -0
  53. mdb_engine/di/__init__.py +34 -0
  54. mdb_engine/di/container.py +248 -0
  55. mdb_engine/di/providers.py +205 -0
  56. mdb_engine/di/scopes.py +139 -0
  57. mdb_engine/embeddings/README.md +54 -24
  58. mdb_engine/embeddings/__init__.py +31 -24
  59. mdb_engine/embeddings/dependencies.py +37 -154
  60. mdb_engine/embeddings/service.py +11 -25
  61. mdb_engine/exceptions.py +92 -0
  62. mdb_engine/indexes/README.md +30 -13
  63. mdb_engine/indexes/__init__.py +1 -0
  64. mdb_engine/indexes/helpers.py +1 -1
  65. mdb_engine/indexes/manager.py +50 -114
  66. mdb_engine/memory/README.md +2 -2
  67. mdb_engine/memory/__init__.py +1 -2
  68. mdb_engine/memory/service.py +30 -87
  69. mdb_engine/observability/README.md +4 -2
  70. mdb_engine/observability/__init__.py +26 -9
  71. mdb_engine/observability/health.py +8 -9
  72. mdb_engine/observability/metrics.py +32 -12
  73. mdb_engine/repositories/__init__.py +34 -0
  74. mdb_engine/repositories/base.py +325 -0
  75. mdb_engine/repositories/mongo.py +233 -0
  76. mdb_engine/repositories/unit_of_work.py +166 -0
  77. mdb_engine/routing/README.md +1 -1
  78. mdb_engine/routing/__init__.py +1 -3
  79. mdb_engine/routing/websockets.py +25 -60
  80. mdb_engine-0.2.0.dist-info/METADATA +313 -0
  81. mdb_engine-0.2.0.dist-info/RECORD +96 -0
  82. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  83. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  84. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
  85. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
  86. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
  87. {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, AsyncIOMotorDatabase
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 (HealthChecker, check_engine_health,
28
- check_mongodb_health, check_pool_health)
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 mongo_db(self) -> AsyncIOMotorDatabase:
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
- Get the MongoDB database.
289
+ Check if engine is initialized.
146
290
 
147
291
  Returns:
148
- AsyncIOMotorDatabase instance
292
+ True if the engine has been initialized, False otherwise.
149
293
 
150
- Raises:
151
- RuntimeError: If engine is not initialized
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
- read_scopes: List of app slugs to read from. If None, defaults to
178
- [app_slug]. Allows cross-app data access when needed.
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
- "MongoDBEngine not initialized. Call initialize() first."
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
- return await self._app_registration_manager.register_app(
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
- f" Registered WebSocket route '{path}' for app '{slug}' using APIRouter"
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 (HealthCheckResult,
723
- HealthStatus)
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)