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,747 @@
1
+ """
2
+ FastAPI Authentication and Authorization Dependencies
3
+
4
+ Provides FastAPI dependency functions for authentication and authorization.
5
+
6
+ This module is part of MDB_ENGINE - MongoDB Engine.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import uuid
12
+ from datetime import datetime, timedelta
13
+ from typing import Any, Dict, Mapping, Optional, Tuple
14
+
15
+ import jwt
16
+ from fastapi import Cookie, Depends, HTTPException, Request, status
17
+
18
+ from ..exceptions import ConfigurationError
19
+ from .jwt import decode_jwt_token, extract_token_metadata
20
+ # Import from local modules
21
+ from .provider import AuthorizationProvider
22
+ from .session_manager import SessionManager
23
+ from .token_store import TokenBlacklist
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _SECRET_KEY_CACHE: Optional[str] = None
28
+
29
+
30
+ def _get_secret_key() -> str:
31
+ """
32
+ Get and validate SECRET_KEY from environment (lazy evaluation).
33
+
34
+ Raises:
35
+ ConfigurationError: If SECRET_KEY is not set or too weak
36
+ """
37
+ global _SECRET_KEY_CACHE
38
+
39
+ if _SECRET_KEY_CACHE is not None:
40
+ return _SECRET_KEY_CACHE
41
+
42
+ secret_key = os.environ.get("FLASK_SECRET_KEY") or os.environ.get("SECRET_KEY")
43
+
44
+ if not secret_key:
45
+ raise ConfigurationError(
46
+ "FLASK_SECRET_KEY environment variable is required for JWT token security. "
47
+ "Set a strong secret key (minimum 32 characters, cryptographically random). "
48
+ "Example: export FLASK_SECRET_KEY=$(python -c "
49
+ "'import secrets; print(secrets.token_urlsafe(32))')",
50
+ config_key="FLASK_SECRET_KEY",
51
+ )
52
+
53
+ if len(secret_key) < 32:
54
+ logger.warning(
55
+ f"SECRET_KEY is only {len(secret_key)} characters. "
56
+ "Recommendation: Use at least 32 characters for production."
57
+ )
58
+
59
+ _SECRET_KEY_CACHE = secret_key
60
+ return _SECRET_KEY_CACHE
61
+
62
+
63
+ class _SecretKey:
64
+ """Lazy-validated secret key that behaves like a string."""
65
+
66
+ def __str__(self) -> str:
67
+ return _get_secret_key()
68
+
69
+ def __repr__(self) -> str:
70
+ return "<SECRET_KEY (validated on access)>"
71
+
72
+ def __eq__(self, other: Any) -> bool:
73
+ return str(self) == str(other) if other else False
74
+
75
+ def __ne__(self, other: Any) -> bool:
76
+ return not self.__eq__(other)
77
+
78
+ def __hash__(self) -> int:
79
+ return hash(str(self))
80
+
81
+
82
+ def _get_secret_key_value() -> str:
83
+ """Get SECRET_KEY as a string value (for use in function calls)."""
84
+ return _get_secret_key()
85
+
86
+
87
+ SECRET_KEY = _SecretKey()
88
+
89
+
90
+ def _validate_next_url(next_url: Optional[str]) -> str:
91
+ """
92
+ Sanitizes a 'next' URL parameter to prevent Open Redirect vulnerabilities.
93
+ """
94
+ if not next_url:
95
+ return "/"
96
+
97
+ if next_url.startswith("/") and "//" not in next_url and ":" not in next_url:
98
+ return next_url
99
+
100
+ logger.warning(
101
+ f"Blocked potentially unsafe redirect attempt. "
102
+ f"Original 'next' URL: '{next_url}'. Sanitized to '/'."
103
+ )
104
+ return "/"
105
+
106
+
107
+ async def get_authz_provider(request: Request) -> AuthorizationProvider:
108
+ """
109
+ FastAPI Dependency: Retrieves the shared, pluggable AuthZ provider
110
+ from app.state.
111
+ """
112
+ # This key 'authz_provider' will be set in main.py's lifespan
113
+ provider = getattr(request.app.state, "authz_provider", None)
114
+ if not provider:
115
+ logger.critical("❌ get_authz_provider: AuthZ provider not found on app.state!")
116
+ raise HTTPException(
117
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
118
+ detail="Server configuration error: Authorization engine not loaded.",
119
+ )
120
+ return provider
121
+
122
+
123
+ async def get_token_blacklist(request: Request) -> Optional[TokenBlacklist]:
124
+ """
125
+ FastAPI Dependency: Retrieves token blacklist from app.state.
126
+
127
+ Returns None if blacklist is not configured (backward compatibility).
128
+ """
129
+ blacklist = getattr(request.app.state, "token_blacklist", None)
130
+ return blacklist
131
+
132
+
133
+ async def get_session_manager(request: Request) -> Optional[SessionManager]:
134
+ """
135
+ FastAPI Dependency: Retrieves session manager from app.state.
136
+
137
+ Returns None if session manager is not configured (backward compatibility).
138
+ """
139
+ session_mgr = getattr(request.app.state, "session_manager", None)
140
+ return session_mgr
141
+
142
+
143
+ async def get_current_user(
144
+ request: Request,
145
+ token: Optional[str] = Cookie(default=None),
146
+ ) -> Optional[Dict[str, Any]]:
147
+ """
148
+ FastAPI Dependency: Decodes and validates the JWT stored in the 'token' cookie.
149
+
150
+ Enhanced with token blacklist checking if blacklist is available.
151
+ """
152
+ if not token:
153
+ logger.debug("get_current_user: No 'token' cookie found.")
154
+ return None
155
+
156
+ try:
157
+ # Extract token metadata first to get jti
158
+ metadata = extract_token_metadata(token, str(SECRET_KEY))
159
+ jti = metadata.get("jti") if metadata else None
160
+
161
+ # Check blacklist if available
162
+ if jti:
163
+ blacklist = await get_token_blacklist(request)
164
+ if blacklist:
165
+ is_revoked = await blacklist.is_revoked(jti)
166
+ if is_revoked:
167
+ logger.info(
168
+ f"get_current_user: Token {jti} is blacklisted (revoked)"
169
+ )
170
+ return None
171
+
172
+ # Also check user-level revocation
173
+ user_id = metadata.get("user_id") or metadata.get("email")
174
+ if user_id:
175
+ user_revoked = await blacklist.is_user_revoked(user_id)
176
+ if user_revoked:
177
+ logger.info(
178
+ f"get_current_user: All tokens for user {user_id} are revoked"
179
+ )
180
+ return None
181
+
182
+ payload = decode_jwt_token(token, str(SECRET_KEY))
183
+
184
+ # Verify token type (should be access token for backward compatibility, or no type)
185
+ token_type = payload.get("type")
186
+ if token_type and token_type not in ("access", None):
187
+ logger.warning(
188
+ f"get_current_user: Invalid token type '{token_type}' for access token"
189
+ )
190
+ return None
191
+
192
+ logger.debug(
193
+ f"get_current_user: Token successfully decoded for user "
194
+ f"'{payload.get('email', 'N/A')}'."
195
+ )
196
+ return payload
197
+ except jwt.ExpiredSignatureError:
198
+ logger.info("get_current_user: Authentication token has expired.")
199
+ return None
200
+ except jwt.InvalidTokenError as e:
201
+ logger.warning(f"get_current_user: Invalid JWT token presented: {e}")
202
+ return None
203
+ except (ValueError, TypeError):
204
+ logger.exception("Validation error decoding JWT token")
205
+ return None
206
+ except Exception:
207
+ logger.exception("Unexpected error decoding JWT token")
208
+ # Re-raise unexpected errors for debugging
209
+ raise
210
+
211
+
212
+ async def get_current_user_from_request(request: Request) -> Optional[Dict[str, Any]]:
213
+ """
214
+ Helper function to get current user from a Request object.
215
+ This is useful when you need to call get_current_user outside of FastAPI dependency injection.
216
+
217
+ Args:
218
+ request: FastAPI Request object
219
+
220
+ Returns:
221
+ Optional[Dict[str, Any]]: User dict if authenticated, None otherwise
222
+ """
223
+ token = request.cookies.get("token")
224
+ if not token:
225
+ logger.debug("get_current_user_from_request: No 'token' cookie found.")
226
+ return None
227
+
228
+ try:
229
+ # Extract token metadata first to get jti
230
+ metadata = extract_token_metadata(token, str(SECRET_KEY))
231
+ jti = metadata.get("jti") if metadata else None
232
+
233
+ # Check blacklist if available
234
+ if jti:
235
+ blacklist = await get_token_blacklist(request)
236
+ if blacklist:
237
+ is_revoked = await blacklist.is_revoked(jti)
238
+ if is_revoked:
239
+ logger.info(
240
+ f"get_current_user_from_request: Token {jti} is blacklisted (revoked)"
241
+ )
242
+ return None
243
+
244
+ # Also check user-level revocation
245
+ user_id = metadata.get("user_id") or metadata.get("email")
246
+ if user_id:
247
+ user_revoked = await blacklist.is_user_revoked(user_id)
248
+ if user_revoked:
249
+ logger.info(
250
+ f"get_current_user_from_request: All tokens for user "
251
+ f"{user_id} are revoked"
252
+ )
253
+ return None
254
+
255
+ payload = decode_jwt_token(token, str(SECRET_KEY))
256
+
257
+ # Verify token type (should be access token for backward compatibility, or no type)
258
+ token_type = payload.get("type")
259
+ if token_type and token_type not in ("access", None):
260
+ logger.warning(
261
+ f"get_current_user_from_request: Invalid token type '{token_type}' for access token"
262
+ )
263
+ return None
264
+
265
+ logger.debug(
266
+ f"get_current_user_from_request: Token successfully decoded for user "
267
+ f"'{payload.get('email', 'N/A')}'."
268
+ )
269
+ return payload
270
+ except jwt.ExpiredSignatureError:
271
+ logger.info("get_current_user_from_request: Authentication token has expired.")
272
+ return None
273
+ except jwt.InvalidTokenError as e:
274
+ logger.debug(f"get_current_user_from_request: Invalid JWT token presented: {e}")
275
+ return None
276
+ except (ValueError, TypeError):
277
+ logger.exception("Validation error decoding JWT token from request")
278
+ return None
279
+ except Exception:
280
+ logger.exception("Unexpected error decoding JWT token from request")
281
+ # Re-raise unexpected errors for debugging
282
+ raise
283
+
284
+
285
+ async def get_refresh_token(
286
+ request: Request,
287
+ refresh_token: Optional[str] = Cookie(default=None),
288
+ ) -> Optional[Dict[str, Any]]:
289
+ """
290
+ FastAPI Dependency: Validates refresh token from cookie.
291
+
292
+ Args:
293
+ request: FastAPI Request object
294
+ refresh_token: Refresh token from cookie (default cookie name: 'refresh_token')
295
+
296
+ Returns:
297
+ Decoded refresh token payload or None if invalid
298
+ """
299
+ if not refresh_token:
300
+ # Try alternative cookie name
301
+ refresh_token = request.cookies.get("refresh_token")
302
+ if not refresh_token:
303
+ logger.debug("get_refresh_token: No refresh token cookie found.")
304
+ return None
305
+
306
+ try:
307
+ # Extract token metadata first
308
+ metadata = extract_token_metadata(refresh_token, SECRET_KEY)
309
+ jti = metadata.get("jti") if metadata else None
310
+
311
+ # Check blacklist if available
312
+ if jti:
313
+ blacklist = await get_token_blacklist(request)
314
+ if blacklist:
315
+ is_revoked = await blacklist.is_revoked(jti)
316
+ if is_revoked:
317
+ logger.info(
318
+ f"get_refresh_token: Refresh token {jti} is blacklisted"
319
+ )
320
+ return None
321
+
322
+ payload = decode_jwt_token(refresh_token, str(SECRET_KEY))
323
+
324
+ # Verify token type
325
+ token_type = payload.get("type")
326
+ if token_type != "refresh":
327
+ logger.warning(
328
+ f"get_refresh_token: Invalid token type '{token_type}' for refresh token"
329
+ )
330
+ return None
331
+
332
+ # Check session if available
333
+ session_mgr = await get_session_manager(request)
334
+ if session_mgr and jti:
335
+ session = await session_mgr.get_session_by_refresh_token(jti)
336
+ if not session or not session.get("active"):
337
+ logger.info(
338
+ f"get_refresh_token: Session not found or inactive for refresh token {jti}"
339
+ )
340
+ return None
341
+
342
+ # Validate session fingerprint if enabled
343
+ from .config_helpers import get_session_fingerprinting_config
344
+
345
+ fingerprinting_config = get_session_fingerprinting_config(request)
346
+ if fingerprinting_config.get("enabled", True) and fingerprinting_config.get(
347
+ "validate_on_refresh", True
348
+ ):
349
+ stored_fingerprint = session.get("session_fingerprint")
350
+ if stored_fingerprint:
351
+ from .utils import generate_session_fingerprint
352
+
353
+ device_id = request.cookies.get("device_id") or payload.get(
354
+ "device_id"
355
+ )
356
+ if device_id:
357
+ current_fingerprint = generate_session_fingerprint(
358
+ request, device_id
359
+ )
360
+ if current_fingerprint != stored_fingerprint:
361
+ logger.warning(
362
+ f"get_refresh_token: Session fingerprint mismatch "
363
+ f"for refresh token {jti}"
364
+ )
365
+ return None
366
+
367
+ logger.debug(
368
+ f"get_refresh_token: Refresh token validated for user '{payload.get('email', 'N/A')}'"
369
+ )
370
+ return payload
371
+ except jwt.ExpiredSignatureError:
372
+ logger.info("get_refresh_token: Refresh token has expired.")
373
+ return None
374
+ except jwt.InvalidTokenError as e:
375
+ logger.warning(f"get_refresh_token: Invalid refresh token: {e}")
376
+ return None
377
+ except (ValueError, TypeError):
378
+ logger.exception("Validation error decoding refresh token")
379
+ return None
380
+ except Exception:
381
+ logger.exception("Unexpected error decoding refresh token")
382
+ # Re-raise unexpected errors for debugging
383
+ raise
384
+
385
+
386
+ async def require_admin(
387
+ user: Optional[Mapping[str, Any]] = Depends(get_current_user),
388
+ authz: AuthorizationProvider = Depends(get_authz_provider),
389
+ ) -> Dict[str, Any]:
390
+ """
391
+ FastAPI Dependency: Enforces admin privileges via the pluggable AuthZ provider.
392
+ """
393
+ user_identifier = "anonymous"
394
+ has_perm = False
395
+
396
+ if user and user.get("email"):
397
+ user_identifier = user.get("email")
398
+ # Use the generic, async interface method
399
+ has_perm = await authz.check(
400
+ subject=user_identifier,
401
+ resource="admin_panel",
402
+ action="access",
403
+ user_object=dict(user), # Pass full context
404
+ )
405
+
406
+ if not has_perm:
407
+ logger.warning(
408
+ f"require_admin: Admin access DENIED for {user_identifier}. Failed provider check."
409
+ )
410
+ raise HTTPException(
411
+ status_code=status.HTTP_403_FORBIDDEN,
412
+ detail="Administrator privileges are required to access this resource.",
413
+ )
414
+
415
+ logger.debug(
416
+ f"require_admin: Admin access GRANTED for user '{user.get('email')}' "
417
+ f"via {authz.__class__.__name__}."
418
+ )
419
+ return dict(user)
420
+
421
+
422
+ async def require_admin_or_developer(
423
+ user: Optional[Mapping[str, Any]] = Depends(get_current_user),
424
+ authz: AuthorizationProvider = Depends(get_authz_provider),
425
+ ) -> Dict[str, Any]:
426
+ """
427
+ FastAPI Dependency: Enforces admin OR developer privileges.
428
+ Developers can upload apps, admins can upload any app.
429
+ """
430
+ if not user:
431
+ raise HTTPException(
432
+ status_code=status.HTTP_401_UNAUTHORIZED,
433
+ detail="Authentication required to upload apps.",
434
+ )
435
+
436
+ user_email = user.get("email")
437
+ if not user_email:
438
+ raise HTTPException(
439
+ status_code=status.HTTP_401_UNAUTHORIZED,
440
+ detail="Invalid authentication token.",
441
+ )
442
+
443
+ # Check if user is an admin
444
+ is_admin = await authz.check(
445
+ subject=user_email,
446
+ resource="admin_panel",
447
+ action="access",
448
+ user_object=dict(user),
449
+ )
450
+
451
+ if is_admin:
452
+ logger.debug(
453
+ f"require_admin_or_developer: Admin '{user_email}' granted access to upload apps"
454
+ )
455
+ return dict(user)
456
+
457
+ # Check if user is a developer (has apps:manage_own permission)
458
+ is_developer = await authz.check(
459
+ subject=user_email,
460
+ resource="experiments",
461
+ action="manage_own",
462
+ user_object=dict(user),
463
+ )
464
+
465
+ if is_developer:
466
+ logger.debug(
467
+ f"require_admin_or_developer: Developer '{user_email}' granted "
468
+ f"access to upload experiments"
469
+ )
470
+ return dict(user)
471
+
472
+ # Neither admin nor developer
473
+ logger.warning(
474
+ f"require_admin_or_developer: Access DENIED for '{user_email}'. "
475
+ f"User is not an admin or developer."
476
+ )
477
+ raise HTTPException(
478
+ status_code=status.HTTP_403_FORBIDDEN,
479
+ detail="Administrator or developer privileges are required to upload experiments.",
480
+ )
481
+
482
+
483
+ async def get_current_user_or_redirect(
484
+ request: Request, user: Optional[Mapping[str, Any]] = Depends(get_current_user)
485
+ ) -> Dict[str, Any]:
486
+ """
487
+ FastAPI Dependency: Enforces user authentication. Redirects to login if not authenticated.
488
+ """
489
+ if not user:
490
+ try:
491
+ login_route_name = "login_get"
492
+ login_url = request.url_for(login_route_name)
493
+ original_path = request.url.path
494
+ safe_next_path = _validate_next_url(original_path)
495
+ redirect_url = f"{login_url}?next={safe_next_path}"
496
+
497
+ logger.info(
498
+ f"get_current_user_or_redirect: User not authenticated. "
499
+ f"Redirecting to login. Original path: '{original_path}', "
500
+ f"Redirect URL: '{redirect_url}'"
501
+ )
502
+ raise HTTPException(
503
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
504
+ headers={"Location": redirect_url},
505
+ detail="Not authenticated. Redirecting to login.",
506
+ )
507
+ except (ValueError, KeyError, AttributeError):
508
+ logger.exception(
509
+ f"Failed to generate login redirect URL for route '{login_route_name}'"
510
+ )
511
+ raise HTTPException(
512
+ status_code=status.HTTP_401_UNAUTHORIZED,
513
+ detail="Authentication required, but redirect failed.",
514
+ )
515
+ return dict(user)
516
+
517
+
518
+ def require_permission(obj: str, act: str, force_login: bool = True):
519
+ """
520
+ Dependency Factory: Creates a dependency checking for a specific permission
521
+ using the pluggable AuthZ provider.
522
+
523
+ Args:
524
+ obj: The resource (object) to check.
525
+ act: The action (permission) to check.
526
+ force_login: If True (default), uses `get_current_user_or_redirect`.
527
+ If False, uses `get_current_user` and checks permissions
528
+ for 'anonymous' if no user is found.
529
+ """
530
+
531
+ # 1. Choose the correct user dependency based on the flag
532
+ user_dependency = get_current_user_or_redirect if force_login else get_current_user
533
+
534
+ async def _check_permission(
535
+ # 2. The type hint MUST be Optional now
536
+ user: Optional[Dict[str, Any]] = Depends(user_dependency),
537
+ # 3. Ask for the generic INTERFACE
538
+ authz: AuthorizationProvider = Depends(get_authz_provider),
539
+ ) -> Optional[Dict[str, Any]]: # 4. Return type is also Optional
540
+ """Internal dependency function performing the AuthZ check."""
541
+
542
+ # 5. Check for 'anonymous' if user is None
543
+ user_email = user.get("email") if user else "anonymous"
544
+
545
+ if not user_email:
546
+ # This should be unreachable if 'anonymous' is the fallback
547
+ raise HTTPException(
548
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated."
549
+ )
550
+
551
+ # 6. Use the generic, async interface method
552
+ has_perm = await authz.check(
553
+ subject=user_email,
554
+ resource=obj,
555
+ action=act,
556
+ user_object=user, # Pass full context (or None)
557
+ )
558
+
559
+ if not has_perm:
560
+ logger.warning(
561
+ f"require_permission: Access DENIED for user '{user_email}' to ('{obj}', '{act}')."
562
+ )
563
+
564
+ # 7. Handle the failure
565
+ if not user:
566
+ # User is anonymous and lacks permission.
567
+ # 401 suggests logging in might help.
568
+ raise HTTPException(
569
+ status_code=status.HTTP_401_UNAUTHORIZED,
570
+ detail=f"You must be logged in with permission to '{act}' on '{obj}'.",
571
+ )
572
+ else:
573
+ # User is logged in but lacks permission
574
+ raise HTTPException(
575
+ status_code=status.HTTP_403_FORBIDDEN,
576
+ detail=(
577
+ f"You do not have permission to perform '{act}' "
578
+ f"on the resource '{obj}'."
579
+ ),
580
+ )
581
+
582
+ logger.debug(
583
+ f"require_permission: Access GRANTED for user '{user_email}' to ('{obj}', '{act}')."
584
+ )
585
+ return user # Returns the user dict or None
586
+
587
+ return _check_permission
588
+
589
+
590
+ # require_experiment_access and require_experiment_ownership_or_admin
591
+ # depend on get_experiment_config from the application layer.
592
+ # These can be imported from the application layer when needed.
593
+
594
+
595
+ async def refresh_access_token(
596
+ request: Request,
597
+ refresh_token_payload: Dict[str, Any],
598
+ device_info: Optional[Dict[str, Any]] = None,
599
+ ) -> Optional[Tuple[str, str, Dict[str, Any]]]:
600
+ """
601
+ Refresh an access token using a valid refresh token.
602
+
603
+ This function:
604
+ 1. Validates the refresh token
605
+ 2. Checks session status
606
+ 3. Generates new token pair (with rotation if enabled)
607
+ 4. Updates session activity
608
+ 5. Revokes old refresh token if rotation is enabled
609
+
610
+ Args:
611
+ request: FastAPI Request object
612
+ refresh_token_payload: Decoded refresh token payload
613
+ device_info: Optional device information for new tokens
614
+
615
+ Returns:
616
+ Tuple of (access_token, refresh_token, metadata) or None if refresh failed
617
+ """
618
+ try:
619
+ from ..config import TOKEN_ROTATION_ENABLED
620
+ from .jwt import generate_token_pair
621
+
622
+ user_id = refresh_token_payload.get("user_id") or refresh_token_payload.get(
623
+ "email"
624
+ )
625
+ old_refresh_jti = refresh_token_payload.get("jti")
626
+ device_id = refresh_token_payload.get("device_id")
627
+
628
+ if not user_id:
629
+ logger.warning("refresh_access_token: No user_id in refresh token")
630
+ return None
631
+
632
+ # Check session if available
633
+ session_mgr = await get_session_manager(request)
634
+ session = None
635
+ if session_mgr:
636
+ session = await session_mgr.get_session_by_refresh_token(old_refresh_jti)
637
+ if not session or not session.get("active"):
638
+ logger.warning(
639
+ f"refresh_access_token: Session not found or inactive for {old_refresh_jti}"
640
+ )
641
+ return None
642
+
643
+ # Validate session fingerprint if enabled
644
+ from .config_helpers import get_session_fingerprinting_config
645
+
646
+ fingerprinting_config = get_session_fingerprinting_config(request)
647
+ if fingerprinting_config.get("enabled", True) and fingerprinting_config.get(
648
+ "validate_on_refresh", True
649
+ ):
650
+ stored_fingerprint = session.get("session_fingerprint")
651
+ if stored_fingerprint:
652
+ from .utils import generate_session_fingerprint
653
+
654
+ device_id = device_id or request.cookies.get("device_id")
655
+ if device_id:
656
+ current_fingerprint = generate_session_fingerprint(
657
+ request, device_id
658
+ )
659
+ if current_fingerprint != stored_fingerprint:
660
+ logger.warning(
661
+ f"refresh_access_token: Session fingerprint mismatch "
662
+ f"for user {user_id}"
663
+ )
664
+ return None
665
+
666
+ # Prepare user data for new tokens
667
+ user_data = {
668
+ "user_id": user_id,
669
+ "email": refresh_token_payload.get("email"),
670
+ }
671
+
672
+ # Use existing device_id or generate new one
673
+ if not device_id:
674
+ device_id = (
675
+ str(uuid.uuid4()) if not device_info else device_info.get("device_id")
676
+ )
677
+
678
+ if device_info:
679
+ device_info["device_id"] = device_id
680
+ else:
681
+ device_info = {"device_id": device_id}
682
+
683
+ # Generate new token pair
684
+ access_token, new_refresh_token, token_metadata = generate_token_pair(
685
+ user_data, str(SECRET_KEY), device_info=device_info
686
+ )
687
+
688
+ # If rotation enabled, revoke old refresh token
689
+ if TOKEN_ROTATION_ENABLED and old_refresh_jti:
690
+ blacklist = await get_token_blacklist(request)
691
+ if blacklist:
692
+ # Get expiry from old token
693
+ from ..config import REFRESH_TOKEN_TTL
694
+
695
+ expires_at = datetime.utcnow() + timedelta(seconds=REFRESH_TOKEN_TTL)
696
+ await blacklist.revoke_token(
697
+ old_refresh_jti,
698
+ user_id=user_id,
699
+ expires_at=expires_at,
700
+ reason="token_rotation",
701
+ )
702
+
703
+ # Revoke old session if rotation enabled
704
+ if session_mgr:
705
+ await session_mgr.revoke_session_by_refresh_token(old_refresh_jti)
706
+
707
+ # Create or update session with new refresh token
708
+ if session_mgr:
709
+ new_refresh_jti = token_metadata.get("refresh_jti")
710
+ ip_address = request.client.host if request.client else None
711
+
712
+ from .utils import generate_session_fingerprint
713
+
714
+ new_fingerprint = (
715
+ generate_session_fingerprint(request, device_id) if device_id else None
716
+ )
717
+
718
+ if old_refresh_jti and TOKEN_ROTATION_ENABLED:
719
+ update_data = {
720
+ "refresh_jti": new_refresh_jti,
721
+ "last_seen": datetime.utcnow(),
722
+ "ip_address": ip_address,
723
+ }
724
+ if new_fingerprint:
725
+ update_data["session_fingerprint"] = new_fingerprint
726
+ await session_mgr.collection.update_one(
727
+ {"refresh_jti": old_refresh_jti}, {"$set": update_data}
728
+ )
729
+ else:
730
+ await session_mgr.create_session(
731
+ user_id=user_id,
732
+ device_id=device_id,
733
+ refresh_jti=new_refresh_jti,
734
+ device_info=device_info,
735
+ ip_address=ip_address,
736
+ session_fingerprint=new_fingerprint,
737
+ )
738
+
739
+ logger.debug(f"refresh_access_token: New tokens generated for user {user_id}")
740
+ return access_token, new_refresh_token, token_metadata
741
+ except (ValueError, TypeError, jwt.InvalidTokenError):
742
+ logger.exception("Validation error refreshing token")
743
+ return None
744
+ except Exception:
745
+ logger.exception("Unexpected error refreshing token")
746
+ # Re-raise unexpected errors for debugging
747
+ raise