mdb-engine 0.1.6__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/README.md +144 -0
  2. mdb_engine/__init__.py +37 -0
  3. mdb_engine/auth/README.md +631 -0
  4. mdb_engine/auth/__init__.py +128 -0
  5. mdb_engine/auth/casbin_factory.py +199 -0
  6. mdb_engine/auth/casbin_models.py +46 -0
  7. mdb_engine/auth/config_defaults.py +71 -0
  8. mdb_engine/auth/config_helpers.py +213 -0
  9. mdb_engine/auth/cookie_utils.py +158 -0
  10. mdb_engine/auth/decorators.py +350 -0
  11. mdb_engine/auth/dependencies.py +747 -0
  12. mdb_engine/auth/helpers.py +64 -0
  13. mdb_engine/auth/integration.py +578 -0
  14. mdb_engine/auth/jwt.py +225 -0
  15. mdb_engine/auth/middleware.py +241 -0
  16. mdb_engine/auth/oso_factory.py +323 -0
  17. mdb_engine/auth/provider.py +570 -0
  18. mdb_engine/auth/restrictions.py +271 -0
  19. mdb_engine/auth/session_manager.py +477 -0
  20. mdb_engine/auth/token_lifecycle.py +213 -0
  21. mdb_engine/auth/token_store.py +289 -0
  22. mdb_engine/auth/users.py +1516 -0
  23. mdb_engine/auth/utils.py +614 -0
  24. mdb_engine/cli/__init__.py +13 -0
  25. mdb_engine/cli/commands/__init__.py +7 -0
  26. mdb_engine/cli/commands/generate.py +105 -0
  27. mdb_engine/cli/commands/migrate.py +83 -0
  28. mdb_engine/cli/commands/show.py +70 -0
  29. mdb_engine/cli/commands/validate.py +63 -0
  30. mdb_engine/cli/main.py +41 -0
  31. mdb_engine/cli/utils.py +92 -0
  32. mdb_engine/config.py +217 -0
  33. mdb_engine/constants.py +160 -0
  34. mdb_engine/core/README.md +542 -0
  35. mdb_engine/core/__init__.py +42 -0
  36. mdb_engine/core/app_registration.py +392 -0
  37. mdb_engine/core/connection.py +243 -0
  38. mdb_engine/core/engine.py +749 -0
  39. mdb_engine/core/index_management.py +162 -0
  40. mdb_engine/core/manifest.py +2793 -0
  41. mdb_engine/core/seeding.py +179 -0
  42. mdb_engine/core/service_initialization.py +355 -0
  43. mdb_engine/core/types.py +413 -0
  44. mdb_engine/database/README.md +522 -0
  45. mdb_engine/database/__init__.py +31 -0
  46. mdb_engine/database/abstraction.py +635 -0
  47. mdb_engine/database/connection.py +387 -0
  48. mdb_engine/database/scoped_wrapper.py +1721 -0
  49. mdb_engine/embeddings/README.md +184 -0
  50. mdb_engine/embeddings/__init__.py +62 -0
  51. mdb_engine/embeddings/dependencies.py +193 -0
  52. mdb_engine/embeddings/service.py +759 -0
  53. mdb_engine/exceptions.py +167 -0
  54. mdb_engine/indexes/README.md +651 -0
  55. mdb_engine/indexes/__init__.py +21 -0
  56. mdb_engine/indexes/helpers.py +145 -0
  57. mdb_engine/indexes/manager.py +895 -0
  58. mdb_engine/memory/README.md +451 -0
  59. mdb_engine/memory/__init__.py +30 -0
  60. mdb_engine/memory/service.py +1285 -0
  61. mdb_engine/observability/README.md +515 -0
  62. mdb_engine/observability/__init__.py +42 -0
  63. mdb_engine/observability/health.py +296 -0
  64. mdb_engine/observability/logging.py +161 -0
  65. mdb_engine/observability/metrics.py +297 -0
  66. mdb_engine/routing/README.md +462 -0
  67. mdb_engine/routing/__init__.py +73 -0
  68. mdb_engine/routing/websockets.py +813 -0
  69. mdb_engine/utils/__init__.py +7 -0
  70. mdb_engine-0.1.6.dist-info/METADATA +213 -0
  71. mdb_engine-0.1.6.dist-info/RECORD +75 -0
  72. mdb_engine-0.1.6.dist-info/WHEEL +5 -0
  73. mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
  74. mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
  75. mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1516 @@
1
+ """
2
+ App-Level User Management Module
3
+
4
+ Provides utilities for app-level user management.
5
+
6
+ This module allows apps to manage their own users and sessions
7
+ separate from platform-level authentication, while maintaining integration
8
+ with the platform's auth system.
9
+
10
+ This module is part of MDB_ENGINE - MongoDB Engine.
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import uuid
16
+ from datetime import datetime, timedelta
17
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
18
+
19
+ import bcrypt
20
+ import jwt
21
+ from fastapi import Request
22
+ from fastapi.responses import Response
23
+
24
+ try:
25
+ from pymongo.errors import (ConnectionFailure, OperationFailure,
26
+ ServerSelectionTimeoutError)
27
+ except ImportError:
28
+ ConnectionFailure = Exception
29
+ OperationFailure = Exception
30
+ ServerSelectionTimeoutError = Exception
31
+
32
+ from .dependencies import SECRET_KEY
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _is_auth_route(request_path: str) -> bool:
38
+ """Check if request path is an authentication route."""
39
+ auth_route_patterns = ["/login", "/register", "/signin", "/signup", "/auth"]
40
+ return any(pattern in request_path.lower() for pattern in auth_route_patterns)
41
+
42
+
43
+ async def _get_app_user_config(
44
+ request: Request,
45
+ slug_id: str,
46
+ config: Optional[Dict[str, Any]],
47
+ get_app_config_func: Optional[Callable[[Request, str, Dict], Awaitable[Dict]]],
48
+ ) -> Optional[Dict[str, Any]]:
49
+ """Fetch and validate app user config."""
50
+ if config is None:
51
+ if not get_app_config_func:
52
+ raise ValueError(
53
+ "config or get_app_config_func must be provided. "
54
+ "Provide either the config dict directly or a callable that returns it."
55
+ )
56
+ config = await get_app_config_func(request, slug_id, {"auth": 1})
57
+
58
+ if not config:
59
+ return None
60
+
61
+ auth = config.get("auth", {})
62
+ users_config = auth.get("users", {})
63
+ if not users_config.get("enabled", False):
64
+ return None
65
+
66
+ return config
67
+
68
+
69
+ def _convert_user_id_to_objectid(user_id: Any) -> Tuple[Any, Optional[str]]:
70
+ """
71
+ Convert user_id to ObjectId if valid, otherwise keep as string.
72
+
73
+ Returns:
74
+ Tuple of (converted_user_id, error_message)
75
+ """
76
+ from bson.objectid import ObjectId
77
+
78
+ if isinstance(user_id, str):
79
+ # Check if it's a valid ObjectId string (24 hex characters)
80
+ if len(user_id) == 24 and all(c in "0123456789abcdefABCDEF" for c in user_id):
81
+ try:
82
+ return ObjectId(user_id), None
83
+ except (TypeError, ValueError):
84
+ # If conversion fails (shouldn't happen after format check), keep as string
85
+ # Type 2: Recoverable - return original string as fallback
86
+ return user_id, None
87
+ # If it's not a valid ObjectId format, keep as string
88
+ return user_id, None
89
+ elif not isinstance(user_id, ObjectId):
90
+ # If it's not a string or ObjectId, try to convert (for backward compatibility)
91
+ try:
92
+ return ObjectId(user_id), None
93
+ except (TypeError, ValueError) as e:
94
+ return None, f"Invalid user_id format: {user_id}: {e}"
95
+ except Exception:
96
+ logger.exception("Unexpected error converting user_id to ObjectId")
97
+ raise
98
+
99
+ return user_id, None
100
+
101
+
102
+ async def _validate_and_decode_session_token(
103
+ session_token: str, slug_id: str
104
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[Exception]]:
105
+ """Validate and decode session token."""
106
+ try:
107
+ from .jwt import decode_jwt_token
108
+
109
+ payload = decode_jwt_token(session_token, str(SECRET_KEY))
110
+
111
+ # Verify it's for this app
112
+ if payload.get("app_slug") != slug_id:
113
+ logger.warning(
114
+ f"Session token for wrong app: expected {slug_id}, got {payload.get('app_slug')}"
115
+ )
116
+ return None, None
117
+
118
+ # Get user ID from token
119
+ user_id = payload.get("app_user_id")
120
+ if not user_id:
121
+ return None, None
122
+
123
+ return payload, None
124
+ except jwt.ExpiredSignatureError:
125
+ logger.debug(f"Session token expired for app {slug_id}")
126
+ return None, jwt.ExpiredSignatureError()
127
+ except jwt.InvalidTokenError as e:
128
+ logger.warning(f"Invalid session token for app {slug_id}: {e}")
129
+ return None, e
130
+ except (ValueError, TypeError) as e:
131
+ logger.exception("Validation error getting app sub user")
132
+ return None, e
133
+
134
+
135
+ async def _fetch_app_user_from_db(
136
+ db, collection_name: str, user_id: Any
137
+ ) -> Optional[Dict[str, Any]]:
138
+ """Fetch user from database."""
139
+ # Use getattr for attribute access (works with both AppDB and ScopedMongoWrapper)
140
+ collection = getattr(db, collection_name)
141
+ user = await collection.find_one({"_id": user_id})
142
+ if not user:
143
+ logger.warning(f"User {user_id} not found in collection {collection_name}")
144
+ return None
145
+
146
+ # Add app user ID to user dict
147
+ user["app_user_id"] = str(user["_id"])
148
+ return user
149
+
150
+
151
+ async def get_app_user(
152
+ request: Request,
153
+ slug_id: str,
154
+ db,
155
+ config: Optional[Dict[str, Any]] = None,
156
+ allow_demo_fallback: bool = False,
157
+ get_app_config_func: Optional[
158
+ Callable[[Request, str, Dict], Awaitable[Dict]]
159
+ ] = None,
160
+ ) -> Optional[Dict[str, Any]]:
161
+ """
162
+ Get app-level user from session cookie.
163
+
164
+ This function handles app-level user management by:
165
+ 1. Checking for app-specific session cookie
166
+ 2. Validating session token
167
+ 3. Returning app user data if authenticated
168
+ 4. If allow_demo_fallback is True and no session, tries demo mode (for allow_demo_access)
169
+
170
+ SECURITY: Demo mode is BLOCKED for authentication routes (login, register, auth).
171
+ Demo users must remain trapped in demo mode - they cannot attempt to login as other users
172
+ or register new accounts. This is a security restriction to prevent privilege escalation.
173
+ Demo users are marked with is_demo=True flag so apps can detect and restrict them.
174
+
175
+ Args:
176
+ request: FastAPI Request object
177
+ slug_id: App slug
178
+ db: Database wrapper (ScopedMongoWrapper or AppDB)
179
+ config: Optional app config (if not provided, fetches from request)
180
+ allow_demo_fallback: If True and no session found, try demo mode for seamless demo access
181
+ (SECURITY: Only works on non-auth routes - demo users cannot access login/registration)
182
+
183
+ Returns:
184
+ Dict with app user data (with is_demo=True flag if demo user), or None if not authenticated
185
+ """
186
+ # Get and validate config
187
+ config = await _get_app_user_config(request, slug_id, config, get_app_config_func)
188
+ if not config:
189
+ return None
190
+
191
+ auth = config.get("auth", {})
192
+ users_config = auth.get("users", {})
193
+ collection_name = users_config.get("collection_name", "users")
194
+
195
+ # SECURITY: Check if this is an authentication route
196
+ is_auth_route = _is_auth_route(request.url.path)
197
+
198
+ # Get session cookie name
199
+ session_cookie_name = users_config.get("session_cookie_name", "app_session")
200
+ cookie_name = f"{session_cookie_name}_{slug_id}"
201
+
202
+ # Get session token from cookie
203
+ session_token = request.cookies.get(cookie_name)
204
+ if not session_token:
205
+ # If no session and demo fallback enabled, try demo mode
206
+ # BUT: SECURITY - Skip demo mode for auth routes
207
+ if allow_demo_fallback and not is_auth_route:
208
+ return await _try_demo_mode(request, slug_id, db, config)
209
+ return None
210
+
211
+ # Validate and decode session token
212
+ payload, error = await _validate_and_decode_session_token(session_token, slug_id)
213
+ if not payload:
214
+ # If token validation failed and demo fallback enabled, try demo mode
215
+ if allow_demo_fallback and not is_auth_route and error:
216
+ return await _try_demo_mode(request, slug_id, db, config)
217
+ return None
218
+
219
+ # Get user ID from token
220
+ user_id = payload.get("app_user_id")
221
+ if not user_id:
222
+ return None
223
+
224
+ # Convert user_id to ObjectId if needed
225
+ user_id, error_msg = _convert_user_id_to_objectid(user_id)
226
+ if user_id is None:
227
+ if error_msg:
228
+ logger.warning(error_msg)
229
+ return None
230
+
231
+ # Fetch user from database
232
+ return await _fetch_app_user_from_db(db, collection_name, user_id)
233
+
234
+
235
+ async def _try_demo_mode(
236
+ request: Request, slug_id: str, db, config: Dict[str, Any]
237
+ ) -> Optional[Dict[str, Any]]:
238
+ """
239
+ Internal helper: Try to authenticate as demo user if demo mode is enabled.
240
+
241
+ This is called when normal authentication fails and allow_demo_access is enabled.
242
+ It gets/creates a demo user automatically, providing seamless demo experience.
243
+
244
+ Args:
245
+ request: FastAPI Request object
246
+ slug_id: App slug
247
+ db: Database wrapper
248
+ config: App config (must contain auth.users block)
249
+
250
+ Returns:
251
+ Demo user dict if demo mode is enabled and demo user is available, None otherwise
252
+ """
253
+ auth = config.get("auth", {})
254
+ users_config = auth.get("users", {})
255
+
256
+ # Check if demo mode is enabled OR intelligent demo auto-linking is enabled
257
+ allow_demo_access = users_config.get("allow_demo_access", False)
258
+ auto_link_demo = users_config.get("auto_link_platform_demo", True)
259
+ seed_strategy = users_config.get("demo_user_seed_strategy", "auto")
260
+
261
+ # Enable demo mode if:
262
+ # 1. allow_demo_access is explicitly true, OR
263
+ # 2. auto_link_platform_demo is true and seed_strategy is auto (intelligent demo support)
264
+ # This allows "batteries included" demo access even without explicit allow_demo_access flag
265
+ if not allow_demo_access:
266
+ if not (auto_link_demo and seed_strategy == "auto"):
267
+ return None
268
+
269
+ # Check if demo user seeding is allowed
270
+ if seed_strategy == "disabled":
271
+ return None
272
+
273
+ try:
274
+ # Get or create demo user
275
+ from ..config import DB_NAME, MONGO_URI
276
+
277
+ logger.info(
278
+ f"Demo mode: Attempting to get/create demo user for '{slug_id}' "
279
+ f"(MONGO_URI={MONGO_URI}, DB_NAME={DB_NAME})"
280
+ )
281
+
282
+ demo_user = await get_or_create_demo_user(
283
+ db, slug_id, config, MONGO_URI, DB_NAME
284
+ )
285
+
286
+ if not demo_user:
287
+ logger.warning(
288
+ f"Demo mode enabled for {slug_id}, but no demo user available. "
289
+ f"Check if platform demo user exists and auto_link_platform_demo is enabled."
290
+ )
291
+ return None
292
+
293
+ logger.info(
294
+ f"Demo mode: Auto-authenticating user '{demo_user.get('email')}' "
295
+ f"for app '{slug_id}' (via intelligent demo auto-linking)"
296
+ )
297
+
298
+ # Mark in request state that this was demo mode (for potential session creation)
299
+ # Note: Session cookie will be created automatically on next request cycle if needed
300
+ # For seamless demo experience, demo mode works without session cookies initially
301
+ request.state.demo_mode_user = demo_user
302
+ request.state.demo_mode_slug = slug_id
303
+
304
+ return demo_user
305
+
306
+ except (
307
+ ValueError,
308
+ TypeError,
309
+ AttributeError,
310
+ RuntimeError,
311
+ ConnectionError,
312
+ KeyError,
313
+ ) as e:
314
+ logger.error(f"Demo mode failed for {slug_id}: {e}", exc_info=True)
315
+ return None
316
+
317
+
318
+ async def create_app_session(
319
+ request: Request,
320
+ slug_id: str,
321
+ user_id: str,
322
+ config: Optional[Dict[str, Any]] = None,
323
+ response: Optional[Response] = None,
324
+ get_app_config_func: Optional[
325
+ Callable[[Request, str, Dict], Awaitable[Dict]]
326
+ ] = None,
327
+ ) -> str:
328
+ """
329
+ Create a app-specific session token and set cookie.
330
+
331
+ Args:
332
+ request: FastAPI Request object
333
+ slug_id: App slug
334
+ user_id: User ID (from app's users collection)
335
+ config: Optional app config
336
+ response: Optional Response object to set cookie on (creates new if None)
337
+
338
+ Returns:
339
+ Session token string
340
+ """
341
+ # Get auth.users config
342
+ if config is None:
343
+ if not get_app_config_func:
344
+ raise ValueError(
345
+ "config or get_app_config_func must be provided. "
346
+ "Provide either the config dict directly or a callable that returns it."
347
+ )
348
+ config = await get_app_config_func(request, slug_id, {"auth": 1})
349
+
350
+ if not config:
351
+ raise ValueError(f"App config not found for {slug_id}")
352
+
353
+ auth = config.get("auth", {})
354
+ users_config = auth.get("users", {})
355
+ if not users_config.get("enabled", False):
356
+ raise ValueError(f"App-level user management not enabled for app {slug_id}")
357
+
358
+ # Get session TTL
359
+ session_ttl = users_config.get("session_ttl_seconds", 86400)
360
+
361
+ # Create JWT payload
362
+ payload = {
363
+ "app_slug": slug_id,
364
+ "app_user_id": str(user_id),
365
+ "exp": datetime.utcnow() + timedelta(seconds=session_ttl),
366
+ "iat": datetime.utcnow(),
367
+ }
368
+
369
+ # Sign token
370
+ # Ensure SECRET_KEY is a string (not bytes) for jwt.encode
371
+ secret_key = str(SECRET_KEY)
372
+ if isinstance(secret_key, bytes):
373
+ secret_key = secret_key.decode("utf-8")
374
+ elif not isinstance(secret_key, str):
375
+ secret_key = str(secret_key)
376
+
377
+ token = jwt.encode(payload, secret_key, algorithm="HS256")
378
+ # Ensure token is a string (some PyJWT versions return bytes)
379
+ if isinstance(token, bytes):
380
+ token = token.decode("utf-8")
381
+ elif not isinstance(token, str):
382
+ token = str(token)
383
+
384
+ # Set cookie if response provided
385
+ if response:
386
+ session_cookie_name = users_config.get("session_cookie_name", "app_session")
387
+ cookie_name = f"{session_cookie_name}_{slug_id}"
388
+
389
+ # Determine secure cookie setting
390
+ should_use_secure = (
391
+ request.url.scheme == "https" or os.getenv("G_NOME_ENV") == "production"
392
+ )
393
+
394
+ response.set_cookie(
395
+ key=cookie_name,
396
+ value=token,
397
+ httponly=True,
398
+ secure=should_use_secure,
399
+ samesite="lax",
400
+ max_age=session_ttl,
401
+ )
402
+
403
+ return token
404
+
405
+
406
+ async def authenticate_app_user(
407
+ db,
408
+ email: str,
409
+ password: str,
410
+ store_id: Optional[str] = None,
411
+ collection_name: str = "users",
412
+ ) -> Optional[Dict[str, Any]]:
413
+ """
414
+ Authenticate a user against app-specific users collection.
415
+
416
+ Args:
417
+ db: Database wrapper
418
+ email: User email
419
+ password: Plain text password
420
+ store_id: Optional store ID filter (for store_factory multi-store scenario)
421
+ collection_name: Collection name for users (default: "users")
422
+
423
+ Returns:
424
+ User dict if authenticated, None otherwise
425
+ """
426
+ try:
427
+ # Validate email format
428
+ if not email or not isinstance(email, str) or "@" not in email:
429
+ logger.debug(f"Invalid email format for authentication: {email}")
430
+ return None
431
+
432
+ # Build query
433
+ query = {"email": email}
434
+ if store_id:
435
+ try:
436
+ from bson.objectid import ObjectId
437
+
438
+ query["store_id"] = ObjectId(store_id)
439
+ except (TypeError, ValueError) as e:
440
+ logger.warning(f"Invalid store_id format: {store_id}: {e}")
441
+ return None
442
+ except Exception:
443
+ logger.exception("Unexpected error converting store_id to ObjectId")
444
+ raise
445
+
446
+ # Find user
447
+ # Use getattr to access collection (works with ScopedMongoWrapper and AppDB)
448
+ collection = getattr(db, collection_name)
449
+ user = await collection.find_one(query)
450
+ if not user:
451
+ return None
452
+
453
+ # Check password (bcrypt only - plain text support removed for security)
454
+ stored_password = user.get("password_hash") or user.get("password")
455
+
456
+ if not stored_password:
457
+ return None
458
+
459
+ # Only allow bcrypt hashed passwords
460
+ if isinstance(stored_password, bytes) or (
461
+ isinstance(stored_password, str) and stored_password.startswith("$2b$")
462
+ ):
463
+ if isinstance(stored_password, str):
464
+ stored_password = stored_password.encode("utf-8")
465
+ if isinstance(password, str):
466
+ password = password.encode("utf-8")
467
+
468
+ if bcrypt.checkpw(password, stored_password):
469
+ return user
470
+ else:
471
+ # Password is not bcrypt hashed - reject for security
472
+ logger.warning(
473
+ f"User {email} has non-bcrypt password hash - password verification rejected"
474
+ )
475
+ return None
476
+
477
+ return None
478
+
479
+ except (ValueError, TypeError):
480
+ logger.exception("Validation error authenticating app user")
481
+ return None
482
+ except Exception:
483
+ logger.exception("Unexpected error authenticating app user")
484
+ # Re-raise unexpected errors for debugging
485
+ raise
486
+
487
+
488
+ async def create_app_user(
489
+ db,
490
+ email: str,
491
+ password: str,
492
+ role: str = "user",
493
+ store_id: Optional[str] = None,
494
+ collection_name: str = "users",
495
+ ) -> Optional[Dict[str, Any]]:
496
+ """
497
+ Create a new user in app-specific users collection.
498
+
499
+ Args:
500
+ db: Database wrapper
501
+ email: User email
502
+ password: Plain text password (will be hashed with bcrypt)
503
+ role: User role (default: "user")
504
+ store_id: Optional store ID (for store_factory)
505
+ collection_name: Collection name for users
506
+
507
+ Returns:
508
+ Created user dict, or None if creation failed
509
+ """
510
+ try:
511
+ import datetime
512
+
513
+ from bson.objectid import ObjectId
514
+
515
+ # Validate email format
516
+ if (
517
+ not email
518
+ or not isinstance(email, str)
519
+ or "@" not in email
520
+ or "." not in email
521
+ ):
522
+ logger.warning(f"Invalid email format: {email}")
523
+ return None
524
+
525
+ # Validate password
526
+ if not password or not isinstance(password, str) or len(password) == 0:
527
+ logger.warning("Invalid password (empty or not a string)")
528
+ return None
529
+
530
+ # Check if user already exists
531
+ query = {"email": email}
532
+ if store_id:
533
+ try:
534
+ query["store_id"] = ObjectId(store_id)
535
+ except (ValueError, TypeError, AttributeError) as e:
536
+ logger.warning(f"Invalid store_id format: {store_id}: {e}")
537
+ return None
538
+
539
+ # Use getattr for attribute access (works with both AppDB and ScopedMongoWrapper)
540
+ collection = getattr(db, collection_name)
541
+ existing = await collection.find_one(query)
542
+ if existing:
543
+ return None
544
+
545
+ # Always hash password (plain text support removed for security)
546
+ try:
547
+ password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
548
+ except ValueError:
549
+ logger.exception("Error encoding password for hashing")
550
+ return None
551
+ except Exception:
552
+ logger.exception("Unexpected error hashing password")
553
+ # Re-raise unexpected errors
554
+ raise
555
+
556
+ # Create user document
557
+ user_doc = {
558
+ "email": email,
559
+ "password_hash": password_hash,
560
+ "role": role,
561
+ "date_created": datetime.datetime.utcnow(),
562
+ }
563
+
564
+ if store_id:
565
+ user_doc["store_id"] = ObjectId(store_id)
566
+
567
+ # Insert user
568
+ # Use getattr for attribute access (works with both AppDB and ScopedMongoWrapper)
569
+ collection = getattr(db, collection_name)
570
+ result = await collection.insert_one(user_doc)
571
+ user_doc["_id"] = result.inserted_id
572
+ user_doc["app_user_id"] = str(result.inserted_id)
573
+
574
+ return user_doc
575
+
576
+ except (ValueError, TypeError):
577
+ logger.exception("Validation error creating app user")
578
+ return None
579
+ except Exception:
580
+ logger.exception("Unexpected error creating app user")
581
+ # Re-raise unexpected errors for debugging
582
+ raise
583
+
584
+
585
+ async def get_or_create_anonymous_user(
586
+ request: Request,
587
+ slug_id: str,
588
+ db,
589
+ config: Optional[Dict[str, Any]] = None,
590
+ get_app_config_func: Optional[
591
+ Callable[[Request, str, Dict], Awaitable[Dict]]
592
+ ] = None,
593
+ ) -> Optional[Dict[str, Any]]:
594
+ """
595
+ Get or create an anonymous user for anonymous_session strategy.
596
+
597
+ Args:
598
+ request: FastAPI Request object
599
+ slug_id: App slug
600
+ db: Database wrapper
601
+ config: Optional app config
602
+
603
+ Returns:
604
+ Anonymous user dict
605
+ """
606
+ # Get auth.users config
607
+ if config is None:
608
+ if not get_app_config_func:
609
+ raise ValueError(
610
+ "config or get_app_config_func must be provided. "
611
+ "Provide either the config dict directly or a callable that returns it."
612
+ )
613
+ config = await get_app_config_func(request, slug_id, {"auth": 1})
614
+
615
+ if not config:
616
+ return None
617
+
618
+ auth = config.get("auth", {})
619
+ users_config = auth.get("users", {})
620
+ if users_config.get("strategy") != "anonymous_session":
621
+ return None
622
+
623
+ # Get or create anonymous user ID from session
624
+ session_cookie_name = users_config.get("session_cookie_name", "app_session")
625
+ cookie_name = f"{session_cookie_name}_{slug_id}"
626
+
627
+ anonymous_id = request.cookies.get(cookie_name)
628
+ if not anonymous_id:
629
+ # Generate new anonymous ID
630
+ prefix = users_config.get("anonymous_user_prefix", "guest")
631
+ anonymous_id = f"{prefix}_{uuid.uuid4().hex[:8]}"
632
+
633
+ # Create or get anonymous user
634
+ collection_name = users_config.get("collection_name", "users")
635
+ # Use getattr to access collection (works with ScopedMongoWrapper and ExperimentDB)
636
+ collection = getattr(db, collection_name)
637
+ user = await collection.find_one({"email": anonymous_id})
638
+
639
+ if not user:
640
+ import datetime
641
+
642
+ user_doc = {
643
+ "email": anonymous_id,
644
+ "role": "anonymous",
645
+ "is_anonymous": True,
646
+ "date_created": datetime.datetime.utcnow(),
647
+ }
648
+ # Use getattr to access collection (works with ScopedMongoWrapper and AppDB)
649
+ collection = getattr(db, collection_name)
650
+ result = await collection.insert_one(user_doc)
651
+ user_doc["_id"] = result.inserted_id
652
+ user = user_doc
653
+
654
+ user["app_user_id"] = str(user["_id"])
655
+ return user
656
+
657
+
658
+ async def get_platform_demo_user(
659
+ mongo_uri: str, db_name: str
660
+ ) -> Optional[Dict[str, Any]]:
661
+ """
662
+ Get platform demo user information from top-level database.
663
+
664
+ Args:
665
+ mongo_uri: MongoDB connection URI
666
+ db_name: Database name
667
+
668
+ Returns:
669
+ Dict with demo user info (email, password from config, user_id) or None if not available
670
+ """
671
+ try:
672
+ from ..config import (DEMO_EMAIL_DEFAULT, DEMO_ENABLED,
673
+ DEMO_PASSWORD_DEFAULT)
674
+
675
+ if not DEMO_ENABLED or not DEMO_EMAIL_DEFAULT:
676
+ return None
677
+
678
+ # Access top-level database
679
+ from motor.motor_asyncio import AsyncIOMotorClient
680
+
681
+ client = AsyncIOMotorClient(mongo_uri)
682
+ top_level_db = client[db_name]
683
+
684
+ # Check if demo user exists
685
+ demo_user = await top_level_db.users.find_one(
686
+ {"email": DEMO_EMAIL_DEFAULT}, {"_id": 1, "email": 1}
687
+ )
688
+ client.close()
689
+
690
+ if demo_user:
691
+ return {
692
+ "email": DEMO_EMAIL_DEFAULT,
693
+ "password": DEMO_PASSWORD_DEFAULT, # For demo purposes
694
+ "platform_user_id": str(demo_user["_id"]),
695
+ "platform_user": demo_user,
696
+ }
697
+
698
+ return None
699
+
700
+ except (
701
+ ValueError,
702
+ TypeError,
703
+ AttributeError,
704
+ RuntimeError,
705
+ ConnectionError,
706
+ KeyError,
707
+ ) as e:
708
+ logger.error(f"Error getting platform demo user: {e}", exc_info=True)
709
+ return None
710
+
711
+
712
+ async def _link_platform_demo_user(
713
+ db, slug_id: str, collection_name: str, mongo_uri: str, db_name: str
714
+ ) -> Optional[Dict[str, Any]]:
715
+ """Link platform demo user to app demo user."""
716
+ import datetime
717
+
718
+ try:
719
+ logger.debug(
720
+ f"ensure_demo_users_exist: Auto-linking platform demo user for '{slug_id}'"
721
+ )
722
+ platform_demo = await get_platform_demo_user(mongo_uri, db_name)
723
+ if not platform_demo:
724
+ logger.warning(
725
+ f"ensure_demo_users_exist: Platform demo user not found for '{slug_id}'"
726
+ )
727
+ return None
728
+
729
+ # Check if app demo user already exists for platform demo
730
+ collection = getattr(db, collection_name)
731
+ existing = await collection.find_one({"email": platform_demo["email"]})
732
+
733
+ if existing:
734
+ return existing
735
+
736
+ # Create app demo user linked to platform demo
737
+ platform_password = platform_demo.get("password", "demo123")
738
+ password_hash = None
739
+ try:
740
+ password_hash = bcrypt.hashpw(
741
+ platform_password.encode("utf-8"), bcrypt.gensalt()
742
+ )
743
+ except (ValueError, TypeError, AttributeError) as e:
744
+ logger.error(
745
+ f"Error hashing password for platform demo user "
746
+ f"{platform_demo['email']}: {e}",
747
+ exc_info=True,
748
+ )
749
+ return None
750
+
751
+ if password_hash:
752
+ user_doc = {
753
+ "email": platform_demo["email"],
754
+ "password_hash": password_hash,
755
+ "role": "user",
756
+ "platform_user_id": platform_demo["platform_user_id"],
757
+ "is_demo": True,
758
+ "date_created": datetime.datetime.utcnow(),
759
+ }
760
+ collection = getattr(db, collection_name)
761
+ result = await collection.insert_one(user_doc)
762
+ user_doc["_id"] = result.inserted_id
763
+ user_doc["app_user_id"] = str(result.inserted_id)
764
+ logger.info(
765
+ f"ensure_demo_users_exist: Created app demo user "
766
+ f"'{platform_demo['email']}' for '{slug_id}'"
767
+ )
768
+ return user_doc
769
+
770
+ return None
771
+ except (
772
+ ValueError,
773
+ TypeError,
774
+ AttributeError,
775
+ RuntimeError,
776
+ ConnectionError,
777
+ KeyError,
778
+ ) as e:
779
+ logger.error(
780
+ f"ensure_demo_users_exist: Error auto-linking platform demo "
781
+ f"user for '{slug_id}': {e}",
782
+ exc_info=True,
783
+ )
784
+ return None
785
+
786
+
787
+ def _validate_demo_user_config(
788
+ demo_user_config: Any, slug_id: str
789
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
790
+ """Validate demo user configuration."""
791
+ if not isinstance(demo_user_config, dict):
792
+ return None, f"Invalid demo_user_config entry (not a dict): {demo_user_config}"
793
+
794
+ extra_data = demo_user_config.get("extra_data", {})
795
+ if not isinstance(extra_data, dict):
796
+ logger.warning(
797
+ f"Invalid extra_data for demo user config (not a dict): {extra_data}"
798
+ )
799
+ extra_data = {}
800
+
801
+ return {
802
+ "email": demo_user_config.get("email"),
803
+ "password": demo_user_config.get("password"),
804
+ "role": demo_user_config.get("role", "user"),
805
+ "auto_create": demo_user_config.get("auto_create", True),
806
+ "link_to_platform": demo_user_config.get("link_to_platform", False),
807
+ "extra_data": extra_data,
808
+ }, None
809
+
810
+
811
+ async def _resolve_demo_user_email_password(
812
+ email: Optional[str],
813
+ password: Optional[str],
814
+ mongo_uri: Optional[str],
815
+ db_name: Optional[str],
816
+ slug_id: str,
817
+ ) -> Tuple[Optional[str], Optional[str]]:
818
+ """Resolve email and password from config or platform demo."""
819
+ # If email not specified, try platform demo
820
+ if not email:
821
+ if mongo_uri and db_name:
822
+ platform_demo = await get_platform_demo_user(mongo_uri, db_name)
823
+ if platform_demo:
824
+ email = platform_demo["email"]
825
+ if not password:
826
+ password = platform_demo["password"]
827
+ else:
828
+ logger.warning(
829
+ f"No email specified and platform demo not available for {slug_id}"
830
+ )
831
+ return None, None
832
+ else:
833
+ logger.warning(
834
+ f"No email specified and cannot access platform demo for {slug_id}"
835
+ )
836
+ return None, None
837
+
838
+ # Validate email format
839
+ if not isinstance(email, str) or "@" not in email or "." not in email:
840
+ logger.warning(f"Invalid email format for demo user: {email}")
841
+ return None, None
842
+
843
+ if not password:
844
+ # Try to get from platform demo
845
+ if mongo_uri and db_name:
846
+ platform_demo = await get_platform_demo_user(mongo_uri, db_name)
847
+ if platform_demo and platform_demo.get("email") == email:
848
+ password = platform_demo.get("password")
849
+
850
+ if not password:
851
+ password = "demo123" # Fallback default
852
+
853
+ # Validate password is not empty
854
+ if not password or not isinstance(password, str) or len(password) == 0:
855
+ logger.warning(f"Invalid password for demo user {email}")
856
+ return None, None
857
+
858
+ return email, password
859
+
860
+
861
+ async def _create_demo_user_from_config(
862
+ db,
863
+ slug_id: str,
864
+ collection_name: str,
865
+ email: str,
866
+ password: str,
867
+ role: str,
868
+ extra_data: Dict[str, Any],
869
+ link_to_platform: bool,
870
+ mongo_uri: Optional[str],
871
+ db_name: Optional[str],
872
+ ) -> Optional[Dict[str, Any]]:
873
+ """Create a demo user from configuration."""
874
+ import datetime
875
+
876
+ # Hash password with bcrypt
877
+ try:
878
+ password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
879
+ except (ValueError, TypeError, AttributeError) as e:
880
+ logger.error(
881
+ f"Error hashing password for demo user {email}: {e}", exc_info=True
882
+ )
883
+ return None
884
+
885
+ # Create user document
886
+ user_doc = {
887
+ "email": email,
888
+ "password_hash": password_hash,
889
+ "role": role,
890
+ "is_demo": True,
891
+ "date_created": datetime.datetime.utcnow(),
892
+ }
893
+
894
+ # Link to platform demo if requested
895
+ if link_to_platform and mongo_uri and db_name:
896
+ try:
897
+ platform_demo = await get_platform_demo_user(mongo_uri, db_name)
898
+ if platform_demo and platform_demo.get("email") == email:
899
+ user_doc["platform_user_id"] = platform_demo.get("platform_user_id")
900
+ except (
901
+ ValueError,
902
+ TypeError,
903
+ AttributeError,
904
+ RuntimeError,
905
+ ConnectionError,
906
+ KeyError,
907
+ ) as e:
908
+ logger.warning(f"Could not link platform demo for {email}: {e}")
909
+
910
+ # Handle custom _id from extra_data if provided
911
+ custom_id = extra_data.pop("_id", None)
912
+
913
+ # Add extra data
914
+ user_doc.update(extra_data)
915
+
916
+ # Insert user
917
+ try:
918
+ collection = getattr(db, collection_name)
919
+
920
+ if custom_id:
921
+ user_doc["_id"] = custom_id
922
+ try:
923
+ result = await collection.insert_one(user_doc)
924
+ except (
925
+ OperationFailure,
926
+ ConnectionFailure,
927
+ ServerSelectionTimeoutError,
928
+ ValueError,
929
+ TypeError,
930
+ ) as e:
931
+ # If user already exists with this _id, just fetch it
932
+ if isinstance(e, OperationFailure) and (
933
+ "duplicate" in str(e).lower() or "E11000" in str(e)
934
+ ):
935
+ existing = await collection.find_one({"_id": custom_id})
936
+ if existing:
937
+ logger.info(
938
+ f"Demo user {email} already exists with "
939
+ f"_id={custom_id} for {slug_id}"
940
+ )
941
+ return existing
942
+ raise
943
+ else:
944
+ result = await collection.insert_one(user_doc)
945
+
946
+ user_doc["_id"] = result.inserted_id if not custom_id else custom_id
947
+ user_doc["app_user_id"] = str(user_doc["_id"])
948
+ logger.info(
949
+ f"Created demo user {email} for {slug_id} with _id={user_doc['_id']}"
950
+ )
951
+ return user_doc
952
+ except (
953
+ OperationFailure,
954
+ ConnectionFailure,
955
+ ServerSelectionTimeoutError,
956
+ ValueError,
957
+ TypeError,
958
+ AttributeError,
959
+ RuntimeError,
960
+ ) as e:
961
+ logger.error(
962
+ f"Error creating demo user {email} for {slug_id}: {e}",
963
+ exc_info=True,
964
+ )
965
+ return None
966
+
967
+
968
+ async def ensure_demo_users_exist(
969
+ db,
970
+ slug_id: str,
971
+ config: Optional[Dict[str, Any]] = None,
972
+ mongo_uri: Optional[str] = None,
973
+ db_name: Optional[str] = None,
974
+ ) -> List[Dict[str, Any]]:
975
+ """
976
+ Intelligently ensure demo users exist for a app based on manifest configuration.
977
+
978
+ This function:
979
+ 1. Checks manifest auth.users.demo_users configuration
980
+ 2. If demo_users array is empty, automatically uses platform demo user if available
981
+ 3. Creates app-specific demo users as needed
982
+ 4. Links to platform demo user if configured
983
+
984
+ Args:
985
+ db: Database wrapper
986
+ slug_id: App slug
987
+ config: Optional app config (fetches if not provided)
988
+ mongo_uri: Optional MongoDB URI for accessing platform demo user
989
+ db_name: Optional database name for accessing platform demo user
990
+
991
+ Returns:
992
+ List of demo user dicts that were created or already exist
993
+ """
994
+ if config is None:
995
+ # Config not provided - cannot create demo users without config
996
+ logger.warning(
997
+ f"Config not provided for {slug_id}, skipping demo user creation. "
998
+ f"Pass config explicitly when calling from actor context."
999
+ )
1000
+ return []
1001
+
1002
+ if not config:
1003
+ return []
1004
+
1005
+ auth = config.get("auth", {})
1006
+ users_config = auth.get("users", {})
1007
+ if not users_config.get("enabled", False):
1008
+ return []
1009
+
1010
+ # Check seed strategy
1011
+ seed_strategy = users_config.get("demo_user_seed_strategy", "auto")
1012
+ if seed_strategy == "disabled":
1013
+ return []
1014
+
1015
+ collection_name = users_config.get("collection_name", "users")
1016
+ auto_link = users_config.get("auto_link_platform_demo", True)
1017
+ demo_users_config = users_config.get("demo_users", [])
1018
+
1019
+ created_users = []
1020
+
1021
+ # Auto-link platform demo user if enabled
1022
+ if auto_link and seed_strategy == "auto" and mongo_uri and db_name:
1023
+ platform_user = await _link_platform_demo_user(
1024
+ db, slug_id, collection_name, mongo_uri, db_name
1025
+ )
1026
+ if platform_user:
1027
+ created_users.append(platform_user)
1028
+
1029
+ # Process configured demo_users
1030
+ for demo_user_config in demo_users_config:
1031
+ try:
1032
+ # Validate config structure
1033
+ validated_config, error = _validate_demo_user_config(
1034
+ demo_user_config, slug_id
1035
+ )
1036
+ if not validated_config:
1037
+ if error:
1038
+ logger.warning(error)
1039
+ continue
1040
+
1041
+ # Resolve email and password
1042
+ email, password = await _resolve_demo_user_email_password(
1043
+ validated_config["email"],
1044
+ validated_config["password"],
1045
+ mongo_uri,
1046
+ db_name,
1047
+ slug_id,
1048
+ )
1049
+ if not email or not password:
1050
+ continue
1051
+
1052
+ # Check if user already exists
1053
+ collection = getattr(db, collection_name)
1054
+ existing = await collection.find_one({"email": email})
1055
+ if existing:
1056
+ created_users.append(existing)
1057
+ continue
1058
+
1059
+ if not validated_config["auto_create"]:
1060
+ continue
1061
+
1062
+ # Create user from config
1063
+ user = await _create_demo_user_from_config(
1064
+ db,
1065
+ slug_id,
1066
+ collection_name,
1067
+ email,
1068
+ password,
1069
+ validated_config["role"],
1070
+ validated_config["extra_data"],
1071
+ validated_config["link_to_platform"],
1072
+ mongo_uri,
1073
+ db_name,
1074
+ )
1075
+ if user:
1076
+ created_users.append(user)
1077
+
1078
+ except (
1079
+ ValueError,
1080
+ TypeError,
1081
+ AttributeError,
1082
+ RuntimeError,
1083
+ ConnectionError,
1084
+ KeyError,
1085
+ ) as e:
1086
+ logger.error(
1087
+ f"Error processing demo user config for {slug_id}: {e}",
1088
+ exc_info=True,
1089
+ )
1090
+
1091
+ return created_users
1092
+
1093
+
1094
+ async def get_or_create_demo_user_for_request(
1095
+ request: Request,
1096
+ slug_id: str,
1097
+ db,
1098
+ config: Optional[Dict[str, Any]] = None,
1099
+ get_app_config_func: Optional[
1100
+ Callable[[Request, str, Dict], Awaitable[Dict]]
1101
+ ] = None,
1102
+ ) -> Optional[Dict[str, Any]]:
1103
+ """
1104
+ Get or create a demo user for the current request context.
1105
+
1106
+ This is a convenience function that intelligently:
1107
+ 1. Checks if platform demo user is accessing the app
1108
+ 2. Creates/links app demo user if needed
1109
+ 3. Returns the app demo user
1110
+
1111
+ Args:
1112
+ request: FastAPI Request object
1113
+ slug_id: App slug
1114
+ db: Database wrapper
1115
+ config: Optional app config
1116
+ get_app_config_func: Optional callable to get app config
1117
+
1118
+ Returns:
1119
+ Demo user dict if available, None otherwise
1120
+ """
1121
+ # Check if platform user is demo user
1122
+ try:
1123
+ from .dependencies import get_current_user_from_request
1124
+
1125
+ platform_user = await get_current_user_from_request(request)
1126
+
1127
+ if platform_user:
1128
+ from ..config import DEMO_EMAIL_DEFAULT
1129
+
1130
+ if platform_user.get("email") == DEMO_EMAIL_DEFAULT:
1131
+ # Platform demo user accessing app - ensure app demo exists
1132
+ if config is None:
1133
+ if not get_app_config_func:
1134
+ raise ValueError(
1135
+ "config or get_app_config_func must be provided. "
1136
+ "Provide either the config dict directly or a callable that returns it."
1137
+ )
1138
+ config = await get_app_config_func(request, slug_id, {"auth": 1})
1139
+
1140
+ if config:
1141
+ auth = config.get("auth", {})
1142
+ users_config = auth.get("users", {})
1143
+ if users_config.get("enabled", False):
1144
+ collection_name = users_config.get("collection_name", "users")
1145
+
1146
+ # Check if app demo user exists
1147
+ # Use getattr to access collection (works with ScopedMongoWrapper and AppDB)
1148
+ collection = getattr(db, collection_name)
1149
+ app_demo = await collection.find_one(
1150
+ {"email": DEMO_EMAIL_DEFAULT}
1151
+ )
1152
+
1153
+ if app_demo:
1154
+ app_demo["app_user_id"] = str(app_demo["_id"])
1155
+ return app_demo
1156
+
1157
+ # Try to create it
1158
+ try:
1159
+ from ..config import DB_NAME, MONGO_URI
1160
+
1161
+ await ensure_demo_users_exist(
1162
+ db, slug_id, config, MONGO_URI, DB_NAME
1163
+ )
1164
+ # Use getattr to access collection (works with
1165
+ # ScopedMongoWrapper and AppDB)
1166
+ collection = getattr(db, collection_name)
1167
+ app_demo = await collection.find_one(
1168
+ {"email": DEMO_EMAIL_DEFAULT}
1169
+ )
1170
+ if app_demo:
1171
+ app_demo["app_user_id"] = str(app_demo["_id"])
1172
+ return app_demo
1173
+ except (
1174
+ ValueError,
1175
+ TypeError,
1176
+ AttributeError,
1177
+ RuntimeError,
1178
+ ConnectionError,
1179
+ KeyError,
1180
+ ) as e:
1181
+ logger.warning(f"Could not auto-create demo user: {e}")
1182
+ except (
1183
+ ValueError,
1184
+ TypeError,
1185
+ AttributeError,
1186
+ RuntimeError,
1187
+ ConnectionError,
1188
+ KeyError,
1189
+ ) as e:
1190
+ logger.debug(f"Could not check platform demo user: {e}")
1191
+
1192
+ return None
1193
+
1194
+
1195
+ async def get_or_create_demo_user(
1196
+ db,
1197
+ slug_id: str,
1198
+ config: Dict[str, Any],
1199
+ mongo_uri: Optional[str] = None,
1200
+ db_name: Optional[str] = None,
1201
+ ) -> Optional[Dict[str, Any]]:
1202
+ """
1203
+ Get or create a demo user for an app.
1204
+
1205
+ This function intelligently finds or creates a demo user based on auth.users configuration:
1206
+ 1. Checks demo_users array for configured demo users
1207
+ 2. Falls back to platform demo user if available and auto_link_platform_demo is true
1208
+ 3. Creates the demo user if it doesn't exist
1209
+
1210
+ Args:
1211
+ db: Database wrapper
1212
+ slug_id: App slug
1213
+ config: Experiment config (must contain auth.users block)
1214
+ mongo_uri: Optional MongoDB URI for accessing platform demo user
1215
+ db_name: Optional database name for accessing platform demo user
1216
+
1217
+ Returns:
1218
+ Demo user dict if found/created, None otherwise
1219
+ """
1220
+ auth = config.get("auth", {})
1221
+ users_config = auth.get("users", {})
1222
+ if not users_config.get("enabled", False):
1223
+ return None
1224
+
1225
+ collection_name = users_config.get("collection_name", "users")
1226
+
1227
+ # First, try to ensure demo users exist (will create if needed)
1228
+ try:
1229
+ logger.debug(
1230
+ f"get_or_create_demo_user: Ensuring demo users exist for '{slug_id}' "
1231
+ f"(mongo_uri={'provided' if mongo_uri else 'not provided'}, "
1232
+ f"db_name={'provided' if db_name else 'not provided'})"
1233
+ )
1234
+
1235
+ demo_users = await ensure_demo_users_exist(
1236
+ db, slug_id, config, mongo_uri, db_name
1237
+ )
1238
+
1239
+ if demo_users and len(demo_users) > 0:
1240
+ # Return the first demo user (usually the primary one)
1241
+ demo_user = demo_users[0]
1242
+ if isinstance(demo_user, dict):
1243
+ demo_user["app_user_id"] = str(demo_user.get("_id"))
1244
+ logger.info(
1245
+ f"get_or_create_demo_user: Found/created {len(demo_users)} "
1246
+ f"demo user(s) for '{slug_id}'"
1247
+ )
1248
+ return demo_user
1249
+ else:
1250
+ logger.warning(
1251
+ f"get_or_create_demo_user: ensure_demo_users_exist returned "
1252
+ f"empty list for '{slug_id}'"
1253
+ )
1254
+ except (
1255
+ ValueError,
1256
+ TypeError,
1257
+ AttributeError,
1258
+ RuntimeError,
1259
+ ConnectionError,
1260
+ KeyError,
1261
+ ) as e:
1262
+ logger.error(
1263
+ f"get_or_create_demo_user: Could not ensure demo users exist for '{slug_id}': {e}",
1264
+ exc_info=True,
1265
+ )
1266
+
1267
+ # Fallback: Try to find any demo user in the collection
1268
+ # Look for users with "demo" in email or role
1269
+ try:
1270
+ from ..config import DEMO_EMAIL_DEFAULT
1271
+
1272
+ # Use getattr to access collection (works with ScopedMongoWrapper and AppDB)
1273
+ collection = getattr(db, collection_name)
1274
+ demo_user = await collection.find_one(
1275
+ {
1276
+ "$or": [
1277
+ {"email": DEMO_EMAIL_DEFAULT},
1278
+ {"email": {"$regex": "^demo@", "$options": "i"}},
1279
+ {"role": {"$in": ["demo", "Demo", "DEMO"]}},
1280
+ ]
1281
+ }
1282
+ )
1283
+
1284
+ if demo_user:
1285
+ demo_user["app_user_id"] = str(demo_user.get("_id"))
1286
+ return demo_user
1287
+ except (
1288
+ ValueError,
1289
+ TypeError,
1290
+ AttributeError,
1291
+ RuntimeError,
1292
+ ConnectionError,
1293
+ KeyError,
1294
+ ) as e:
1295
+ logger.debug(f"Could not find demo user: {e}")
1296
+
1297
+ return None
1298
+
1299
+
1300
+ async def ensure_demo_users_for_actor(
1301
+ db, slug_id: str, mongo_uri: str, db_name: str
1302
+ ) -> List[Dict[str, Any]]:
1303
+ """
1304
+ Convenience function for actors to ensure demo users exist.
1305
+
1306
+ This function reads manifest.json from the app directory and automatically
1307
+ ensures demo users are created based on auth.users configuration.
1308
+
1309
+ This is the recommended way for actors to call ensure_demo_users_exist during
1310
+ initialization, as it automatically loads the manifest config.
1311
+
1312
+ Note: If the manifest file is not accessible via filesystem (e.g., not in the expected
1313
+ location), this function gracefully handles the error and returns an empty list.
1314
+ The platform will still auto-detect and link platform demo users on first access
1315
+ via request context.
1316
+
1317
+ Example usage in actor.initialize():
1318
+ from mdb_engine.auth import ensure_demo_users_for_actor
1319
+ demo_users = await ensure_demo_users_for_actor(
1320
+ db=self.db,
1321
+ slug_id=self.write_scope,
1322
+ mongo_uri=self.mongo_uri,
1323
+ db_name=self.db_name
1324
+ )
1325
+ if demo_users:
1326
+ logger.info(f"Ensured {len(demo_users)} demo user(s) exist")
1327
+
1328
+ Args:
1329
+ db: Database wrapper
1330
+ slug_id: App slug
1331
+ mongo_uri: MongoDB connection URI
1332
+ db_name: Database name
1333
+
1334
+ Returns:
1335
+ List of demo user dicts that were created or already exist
1336
+ """
1337
+ try:
1338
+ import json
1339
+ from pathlib import Path
1340
+
1341
+ # Try to load manifest.json from multiple possible locations
1342
+ # First try: relative to users.py location
1343
+ base_dir = Path(__file__).resolve().parent.parent
1344
+ apps_dir = base_dir / "apps" / slug_id
1345
+ manifest_path = apps_dir / "manifest.json"
1346
+
1347
+ # Alternative: try current working directory
1348
+ if not manifest_path.exists():
1349
+ try:
1350
+ from pathlib import Path
1351
+
1352
+ cwd = Path.cwd()
1353
+ alt_path = cwd / "apps" / slug_id / "manifest.json"
1354
+ if alt_path.exists():
1355
+ manifest_path = alt_path
1356
+ logger.debug(f"Using manifest from alternative path: {alt_path}")
1357
+ except OSError:
1358
+ # Type 2: Recoverable - if cwd() fails, just skip alternative path
1359
+ pass
1360
+
1361
+ if not manifest_path.exists():
1362
+ logger.warning(
1363
+ f"Manifest not found at {manifest_path} for {slug_id}. "
1364
+ f"Demo users will be auto-created on first access via request context."
1365
+ )
1366
+ return []
1367
+
1368
+ try:
1369
+ with open(manifest_path, "r") as f:
1370
+ config = json.load(f)
1371
+ except json.JSONDecodeError as e:
1372
+ logger.error(
1373
+ f"Invalid JSON in manifest.json for {slug_id}: {e}", exc_info=True
1374
+ )
1375
+ return []
1376
+
1377
+ # Ensure demo users exist
1378
+ return await ensure_demo_users_exist(db, slug_id, config, mongo_uri, db_name)
1379
+
1380
+ except FileNotFoundError:
1381
+ logger.debug(
1382
+ f"Manifest file not accessible for {slug_id}. "
1383
+ f"Demo users will be auto-created on first access."
1384
+ )
1385
+ return []
1386
+ except PermissionError as e:
1387
+ logger.warning(f"Permission denied reading manifest for {slug_id}: {e}")
1388
+ return []
1389
+ except (
1390
+ ValueError,
1391
+ TypeError,
1392
+ AttributeError,
1393
+ RuntimeError,
1394
+ ConnectionError,
1395
+ KeyError,
1396
+ PermissionError,
1397
+ ) as e:
1398
+ logger.error(
1399
+ f"Error ensuring demo users for actor {slug_id}: {e}", exc_info=True
1400
+ )
1401
+ return []
1402
+
1403
+
1404
+ async def sync_app_user_to_casbin(
1405
+ user: Dict[str, Any],
1406
+ authz_provider,
1407
+ role: Optional[str] = None,
1408
+ app_slug: Optional[str] = None,
1409
+ ) -> bool:
1410
+ """
1411
+ Sync app-level user to Casbin by assigning a role.
1412
+
1413
+ This function automatically assigns a Casbin role to an app-level user,
1414
+ linking the app user ID to the role in Casbin's grouping rules.
1415
+
1416
+ Args:
1417
+ user: App-level user dict (must have _id or app_user_id)
1418
+ authz_provider: AuthorizationProvider instance (should be CasbinAdapter)
1419
+ role: Role name to assign (if None, uses user.get('role') or 'user')
1420
+ app_slug: App slug for logging (optional)
1421
+
1422
+ Returns:
1423
+ True if role was assigned successfully, False otherwise
1424
+ """
1425
+ try:
1426
+ # Check if provider is CasbinAdapter
1427
+ if not hasattr(authz_provider, "_enforcer"):
1428
+ logger.debug(
1429
+ "sync_app_user_to_casbin: Provider is not CasbinAdapter, skipping"
1430
+ )
1431
+ return False
1432
+
1433
+ enforcer = authz_provider._enforcer
1434
+
1435
+ # Get user ID
1436
+ user_id = str(user.get("_id") or user.get("app_user_id", ""))
1437
+ if not user_id:
1438
+ logger.warning("sync_app_user_to_casbin: User has no _id or app_user_id")
1439
+ return False
1440
+
1441
+ # Determine role
1442
+ if not role:
1443
+ role = user.get("role") or "user"
1444
+
1445
+ # Create subject identifier (use app_user_id as subject)
1446
+ subject = user_id
1447
+
1448
+ # Check if role assignment already exists
1449
+ existing_roles = await enforcer.get_roles_for_user(subject)
1450
+ if role in existing_roles:
1451
+ logger.debug(
1452
+ f"sync_app_user_to_casbin: User {subject} already has role {role}"
1453
+ )
1454
+ return True
1455
+
1456
+ # Add grouping policy: user -> role
1457
+ added = await enforcer.add_grouping_policy(subject, role)
1458
+
1459
+ if added:
1460
+ # Save policy to database
1461
+ await enforcer.save_policy()
1462
+ logger.info(
1463
+ f"sync_app_user_to_casbin: Assigned role '{role}' to user '{subject}'"
1464
+ + (f" in app '{app_slug}'" if app_slug else "")
1465
+ )
1466
+ return True
1467
+ else:
1468
+ logger.warning(
1469
+ f"sync_app_user_to_casbin: Failed to assign role '{role}' to user '{subject}'"
1470
+ )
1471
+ return False
1472
+
1473
+ except (
1474
+ ImportError,
1475
+ AttributeError,
1476
+ TypeError,
1477
+ ValueError,
1478
+ RuntimeError,
1479
+ ConnectionError,
1480
+ KeyError,
1481
+ ) as e:
1482
+ logger.error(
1483
+ f"sync_app_user_to_casbin: Error syncing user to Casbin: {e}", exc_info=True
1484
+ )
1485
+ return False
1486
+
1487
+
1488
+ def get_app_user_role(
1489
+ user: Dict[str, Any], config: Optional[Dict[str, Any]] = None
1490
+ ) -> str:
1491
+ """
1492
+ Determine Casbin role for app-level user.
1493
+
1494
+ Args:
1495
+ user: Sub-auth user dict
1496
+ config: Optional app config (for default role)
1497
+
1498
+ Returns:
1499
+ Role name (default: "user")
1500
+ """
1501
+ # Check user's role field
1502
+ role = user.get("role")
1503
+ if role:
1504
+ return str(role)
1505
+
1506
+ # Check config for default role
1507
+ if config:
1508
+ auth_policy = config.get("auth_policy", {})
1509
+ authorization = auth_policy.get("authorization", {})
1510
+ default_roles = authorization.get("default_roles", [])
1511
+ if default_roles:
1512
+ # Use first default role as fallback
1513
+ return default_roles[0]
1514
+
1515
+ # Final fallback
1516
+ return "user"