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.
- mdb_engine/README.md +144 -0
- mdb_engine/__init__.py +37 -0
- mdb_engine/auth/README.md +631 -0
- mdb_engine/auth/__init__.py +128 -0
- mdb_engine/auth/casbin_factory.py +199 -0
- mdb_engine/auth/casbin_models.py +46 -0
- mdb_engine/auth/config_defaults.py +71 -0
- mdb_engine/auth/config_helpers.py +213 -0
- mdb_engine/auth/cookie_utils.py +158 -0
- mdb_engine/auth/decorators.py +350 -0
- mdb_engine/auth/dependencies.py +747 -0
- mdb_engine/auth/helpers.py +64 -0
- mdb_engine/auth/integration.py +578 -0
- mdb_engine/auth/jwt.py +225 -0
- mdb_engine/auth/middleware.py +241 -0
- mdb_engine/auth/oso_factory.py +323 -0
- mdb_engine/auth/provider.py +570 -0
- mdb_engine/auth/restrictions.py +271 -0
- mdb_engine/auth/session_manager.py +477 -0
- mdb_engine/auth/token_lifecycle.py +213 -0
- mdb_engine/auth/token_store.py +289 -0
- mdb_engine/auth/users.py +1516 -0
- mdb_engine/auth/utils.py +614 -0
- mdb_engine/cli/__init__.py +13 -0
- mdb_engine/cli/commands/__init__.py +7 -0
- mdb_engine/cli/commands/generate.py +105 -0
- mdb_engine/cli/commands/migrate.py +83 -0
- mdb_engine/cli/commands/show.py +70 -0
- mdb_engine/cli/commands/validate.py +63 -0
- mdb_engine/cli/main.py +41 -0
- mdb_engine/cli/utils.py +92 -0
- mdb_engine/config.py +217 -0
- mdb_engine/constants.py +160 -0
- mdb_engine/core/README.md +542 -0
- mdb_engine/core/__init__.py +42 -0
- mdb_engine/core/app_registration.py +392 -0
- mdb_engine/core/connection.py +243 -0
- mdb_engine/core/engine.py +749 -0
- mdb_engine/core/index_management.py +162 -0
- mdb_engine/core/manifest.py +2793 -0
- mdb_engine/core/seeding.py +179 -0
- mdb_engine/core/service_initialization.py +355 -0
- mdb_engine/core/types.py +413 -0
- mdb_engine/database/README.md +522 -0
- mdb_engine/database/__init__.py +31 -0
- mdb_engine/database/abstraction.py +635 -0
- mdb_engine/database/connection.py +387 -0
- mdb_engine/database/scoped_wrapper.py +1721 -0
- mdb_engine/embeddings/README.md +184 -0
- mdb_engine/embeddings/__init__.py +62 -0
- mdb_engine/embeddings/dependencies.py +193 -0
- mdb_engine/embeddings/service.py +759 -0
- mdb_engine/exceptions.py +167 -0
- mdb_engine/indexes/README.md +651 -0
- mdb_engine/indexes/__init__.py +21 -0
- mdb_engine/indexes/helpers.py +145 -0
- mdb_engine/indexes/manager.py +895 -0
- mdb_engine/memory/README.md +451 -0
- mdb_engine/memory/__init__.py +30 -0
- mdb_engine/memory/service.py +1285 -0
- mdb_engine/observability/README.md +515 -0
- mdb_engine/observability/__init__.py +42 -0
- mdb_engine/observability/health.py +296 -0
- mdb_engine/observability/logging.py +161 -0
- mdb_engine/observability/metrics.py +297 -0
- mdb_engine/routing/README.md +462 -0
- mdb_engine/routing/__init__.py +73 -0
- mdb_engine/routing/websockets.py +813 -0
- mdb_engine/utils/__init__.py +7 -0
- mdb_engine-0.1.6.dist-info/METADATA +213 -0
- mdb_engine-0.1.6.dist-info/RECORD +75 -0
- mdb_engine-0.1.6.dist-info/WHEEL +5 -0
- mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
- mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
- mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
mdb_engine/auth/users.py
ADDED
|
@@ -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"
|