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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdb_engine/core/engine.py CHANGED
@@ -30,7 +30,7 @@ import os
30
30
  import secrets
31
31
  from contextlib import asynccontextmanager
32
32
  from pathlib import Path
33
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
33
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Tuple
34
34
 
35
35
  from motor.motor_asyncio import AsyncIOMotorClient
36
36
  from pymongo.errors import PyMongoError
@@ -280,7 +280,21 @@ class MongoDBEngine:
280
280
 
281
281
  @property
282
282
  def _initialized(self) -> bool:
283
- """Check if engine is initialized."""
283
+ """Check if engine is initialized (internal)."""
284
+ return self._connection_manager.initialized
285
+
286
+ @property
287
+ def initialized(self) -> bool:
288
+ """
289
+ Check if engine is initialized.
290
+
291
+ Returns:
292
+ True if the engine has been initialized, False otherwise.
293
+
294
+ Example:
295
+ if engine.initialized:
296
+ db = engine.get_scoped_db("my_app")
297
+ """
284
298
  return self._connection_manager.initialized
285
299
 
286
300
  def get_scoped_db(
@@ -861,6 +875,30 @@ class MongoDBEngine:
861
875
  return self._service_initializer.get_memory_service(slug)
862
876
  return None
863
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
+
864
902
  @property
865
903
  def _apps(self) -> Dict[str, Any]:
866
904
  """
@@ -1052,6 +1090,12 @@ class MongoDBEngine:
1052
1090
  slug: str,
1053
1091
  manifest: Path,
1054
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,
1055
1099
  **fastapi_kwargs: Any,
1056
1100
  ) -> "FastAPI":
1057
1101
  """
@@ -1065,20 +1109,33 @@ class MongoDBEngine:
1065
1109
  - "app" (default): Per-app token authentication
1066
1110
  - "shared": Shared user pool with SSO, auto-adds SharedAuthMiddleware
1067
1111
  5. Auto-retrieves app tokens (for "app" mode)
1068
- 6. Shuts down the engine on shutdown
1112
+ 6. Calls on_startup callback (if provided)
1113
+ 7. Shuts down the engine on shutdown (calls on_shutdown first if provided)
1069
1114
 
1070
1115
  Args:
1071
1116
  slug: Application slug (must match manifest slug)
1072
1117
  manifest: Path to manifest.json file
1073
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
1074
1123
  **fastapi_kwargs: Additional arguments passed to FastAPI()
1075
1124
 
1076
1125
  Returns:
1077
1126
  Configured FastAPI application
1078
1127
 
1079
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
+
1080
1133
  engine = MongoDBEngine(mongo_uri=..., db_name=...)
1081
- app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
1134
+ app = engine.create_app(
1135
+ slug="my_app",
1136
+ manifest=Path("manifest.json"),
1137
+ on_startup=my_startup,
1138
+ )
1082
1139
 
1083
1140
  @app.get("/")
1084
1141
  async def index():
@@ -1151,12 +1208,181 @@ class MongoDBEngine:
1151
1208
  logger.info(f"Shared auth mode for '{slug}' - SSO enabled")
1152
1209
  # Initialize shared user pool and set on app.state
1153
1210
  # Middleware was already added at app creation time (lazy version)
1154
- await engine._initialize_shared_user_pool(app)
1211
+ await engine._initialize_shared_user_pool(app, app_manifest)
1155
1212
  else:
1156
1213
  logger.info(f"Per-app auth mode for '{slug}'")
1157
1214
  # Auto-retrieve app token for "app" mode
1158
1215
  await engine.auto_retrieve_app_token(slug)
1159
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
+
1160
1386
  # Expose engine state on app.state
1161
1387
  app.state.engine = engine
1162
1388
  app.state.app_slug = slug
@@ -1165,13 +1391,57 @@ class MongoDBEngine:
1165
1391
  app.state.auth_mode = auth_mode
1166
1392
  app.state.ray_actor = engine.ray_actor
1167
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
+
1168
1410
  yield
1169
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
+
1170
1420
  await engine.shutdown()
1171
1421
 
1172
1422
  # Create FastAPI app
1173
1423
  app = FastAPI(title=app_title, lifespan=lifespan, **fastapi_kwargs)
1174
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
+
1175
1445
  # Add rate limiting middleware FIRST (outermost layer)
1176
1446
  # This ensures rate limiting happens before auth validation
1177
1447
  rate_limits_config = auth_config.get("rate_limits", {})
@@ -1236,6 +1506,7 @@ class MongoDBEngine:
1236
1506
  async def _initialize_shared_user_pool(
1237
1507
  self,
1238
1508
  app: "FastAPI",
1509
+ manifest: Optional[Dict[str, Any]] = None,
1239
1510
  ) -> None:
1240
1511
  """
1241
1512
  Initialize shared user pool, audit log, and set them on app.state.
@@ -1251,6 +1522,7 @@ class MongoDBEngine:
1251
1522
 
1252
1523
  Args:
1253
1524
  app: FastAPI application instance
1525
+ manifest: Optional manifest dict for seeding demo users
1254
1526
  """
1255
1527
  from ..auth.audit import AuthAuditLog
1256
1528
  from ..auth.shared_users import SharedUserPool
@@ -1275,8 +1547,37 @@ class MongoDBEngine:
1275
1547
  # Expose user pool on app.state for middleware to access
1276
1548
  app.state.user_pool = self._shared_user_pool
1277
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
+
1278
1579
  # Initialize audit logging if enabled
1279
- auth_config = getattr(app.state, "manifest", {}).get("auth", {})
1580
+ auth_config = (manifest or {}).get("auth", {})
1280
1581
  audit_config = auth_config.get("audit", {})
1281
1582
  audit_enabled = audit_config.get("enabled", True) # Default: enabled for shared auth
1282
1583
 
@@ -300,24 +300,44 @@ MANIFEST_SCHEMA_V2 = {
300
300
  "initial_policies": {
301
301
  "type": "array",
302
302
  "items": {
303
- "type": "object",
304
- "properties": {
305
- "role": {"type": "string"},
306
- "resource": {
307
- "type": "string",
308
- "default": "documents",
303
+ "oneOf": [
304
+ {
305
+ "type": "array",
306
+ "items": {"type": "string"},
307
+ "minItems": 3,
308
+ "maxItems": 3,
309
+ "description": (
310
+ "Casbin policy as array: "
311
+ '["role", "resource", "action"]'
312
+ ),
309
313
  },
310
- "action": {"type": "string"},
311
- },
312
- "required": ["role", "action"],
313
- "additionalProperties": False,
314
+ {
315
+ "type": "object",
316
+ "properties": {
317
+ "role": {"type": "string"},
318
+ "resource": {
319
+ "type": "string",
320
+ "default": "documents",
321
+ },
322
+ "action": {"type": "string"},
323
+ },
324
+ "required": ["role", "action"],
325
+ "additionalProperties": False,
326
+ "description": (
327
+ "OSO policy as object: "
328
+ '{"role": "admin", "resource": "documents", '
329
+ '"action": "read"}'
330
+ ),
331
+ },
332
+ ],
314
333
  },
315
334
  "description": (
316
- "Initial permission policies to set up "
317
- "in OSO Cloud on startup. Only used "
318
- "when provider is 'oso'. Example: "
319
- '[{"role": "admin", "resource": '
320
- '"documents", "action": "read"}]'
335
+ "Initial permission policies to set up on startup. "
336
+ "For Casbin provider: use arrays like "
337
+ '["admin", "clicks", "read"]. '
338
+ "For OSO Cloud provider: use objects like "
339
+ '{"role": "admin", "resource": "documents", '
340
+ '"action": "read"}.'
321
341
  ),
322
342
  },
323
343
  },
@@ -576,7 +576,34 @@ except (ConnectionFailure, ServerSelectionTimeoutError) as e:
576
576
 
577
577
  ## Integration Examples
578
578
 
579
- ### FastAPI Integration
579
+ ### FastAPI Integration (Recommended)
580
+
581
+ Use the request-scoped `get_scoped_db` dependency from `mdb_engine.dependencies`:
582
+
583
+ ```python
584
+ from fastapi import Depends
585
+ from mdb_engine import MongoDBEngine
586
+ from mdb_engine.dependencies import get_scoped_db
587
+
588
+ engine = MongoDBEngine(mongo_uri="...", db_name="...")
589
+ app = engine.create_app(slug="my_app", manifest=Path("manifest.json"))
590
+
591
+ @app.get("/data")
592
+ async def get_data(db=Depends(get_scoped_db)):
593
+ # db is automatically scoped to "my_app"
594
+ docs = await db.my_collection.find({}).to_list(length=10)
595
+ return {"data": docs}
596
+
597
+ @app.post("/data")
598
+ async def create_data(db=Depends(get_scoped_db)):
599
+ # Writes are automatically scoped to "my_app"
600
+ result = await db.my_collection.insert_one({"name": "New Document"})
601
+ return {"inserted_id": str(result.inserted_id)}
602
+ ```
603
+
604
+ ### Legacy FastAPI Integration
605
+
606
+ For apps not using `engine.create_app()`:
580
607
 
581
608
  ```python
582
609
  from fastapi import FastAPI, Depends