mdb-engine 0.1.6__py3-none-any.whl → 0.1.7__py3-none-any.whl

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