mdb-engine 0.4.0__tar.gz → 0.4.2__tar.gz

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 (104) hide show
  1. {mdb_engine-0.4.0/mdb_engine.egg-info → mdb_engine-0.4.2}/PKG-INFO +1 -1
  2. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/__init__.py +4 -1
  3. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/shared_middleware.py +185 -36
  4. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/shared_users.py +18 -2
  5. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/engine.py +187 -70
  6. {mdb_engine-0.4.0 → mdb_engine-0.4.2/mdb_engine.egg-info}/PKG-INFO +1 -1
  7. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/pyproject.toml +1 -1
  8. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/setup.py +1 -1
  9. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/LICENSE +0 -0
  10. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/MANIFEST.in +0 -0
  11. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/README.md +0 -0
  12. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/README.md +0 -0
  13. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/ARCHITECTURE.md +0 -0
  14. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/README.md +0 -0
  15. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/__init__.py +0 -0
  16. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/audit.py +0 -0
  17. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/base.py +0 -0
  18. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/casbin_factory.py +0 -0
  19. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/casbin_models.py +0 -0
  20. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/config_defaults.py +0 -0
  21. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/config_helpers.py +0 -0
  22. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/cookie_utils.py +0 -0
  23. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/csrf.py +0 -0
  24. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/decorators.py +0 -0
  25. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/dependencies.py +0 -0
  26. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/helpers.py +0 -0
  27. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/integration.py +0 -0
  28. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/jwt.py +0 -0
  29. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/middleware.py +0 -0
  30. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/oso_factory.py +0 -0
  31. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/provider.py +0 -0
  32. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/rate_limiter.py +0 -0
  33. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/restrictions.py +0 -0
  34. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/session_manager.py +0 -0
  35. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/token_lifecycle.py +0 -0
  36. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/token_store.py +0 -0
  37. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/users.py +0 -0
  38. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/auth/utils.py +0 -0
  39. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/__init__.py +0 -0
  40. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/__init__.py +0 -0
  41. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/generate.py +0 -0
  42. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/migrate.py +0 -0
  43. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/show.py +0 -0
  44. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/commands/validate.py +0 -0
  45. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/main.py +0 -0
  46. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/cli/utils.py +0 -0
  47. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/config.py +0 -0
  48. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/constants.py +0 -0
  49. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/README.md +0 -0
  50. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/__init__.py +0 -0
  51. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/app_registration.py +0 -0
  52. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/app_secrets.py +0 -0
  53. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/connection.py +0 -0
  54. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/encryption.py +0 -0
  55. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/index_management.py +0 -0
  56. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/manifest.py +0 -0
  57. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/ray_integration.py +0 -0
  58. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/seeding.py +0 -0
  59. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/service_initialization.py +0 -0
  60. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/core/types.py +0 -0
  61. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/README.md +0 -0
  62. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/__init__.py +0 -0
  63. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/abstraction.py +0 -0
  64. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/connection.py +0 -0
  65. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/query_validator.py +0 -0
  66. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/resource_limiter.py +0 -0
  67. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/database/scoped_wrapper.py +0 -0
  68. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/dependencies.py +0 -0
  69. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/__init__.py +0 -0
  70. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/container.py +0 -0
  71. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/providers.py +0 -0
  72. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/di/scopes.py +0 -0
  73. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/README.md +0 -0
  74. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/__init__.py +0 -0
  75. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/dependencies.py +0 -0
  76. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/embeddings/service.py +0 -0
  77. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/exceptions.py +0 -0
  78. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/README.md +0 -0
  79. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/__init__.py +0 -0
  80. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/helpers.py +0 -0
  81. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/indexes/manager.py +0 -0
  82. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/README.md +0 -0
  83. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/__init__.py +0 -0
  84. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/memory/service.py +0 -0
  85. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/README.md +0 -0
  86. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/__init__.py +0 -0
  87. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/health.py +0 -0
  88. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/logging.py +0 -0
  89. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/observability/metrics.py +0 -0
  90. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/__init__.py +0 -0
  91. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/base.py +0 -0
  92. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/mongo.py +0 -0
  93. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/repositories/unit_of_work.py +0 -0
  94. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/README.md +0 -0
  95. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/__init__.py +0 -0
  96. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/routing/websockets.py +0 -0
  97. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/utils/__init__.py +0 -0
  98. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine/utils/mongo.py +0 -0
  99. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/SOURCES.txt +0 -0
  100. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/dependency_links.txt +0 -0
  101. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/entry_points.txt +0 -0
  102. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/requires.txt +0 -0
  103. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/mdb_engine.egg-info/top_level.txt +0 -0
  104. {mdb_engine-0.4.0 → mdb_engine-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdb-engine
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: MongoDB Engine
5
5
  Home-page: https://github.com/ranfysvalle02/mdb-engine
6
6
  Author: Fabian Valle
@@ -81,7 +81,10 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
81
81
  # Utilities
82
82
  from .utils import clean_mongo_doc, clean_mongo_docs
83
83
 
84
- __version__ = "0.4.0" # Minor version bump: Major multi-app improvements (12 new features)
84
+ __version__ = (
85
+ "0.4.2" # Security release: Fixed auto-role assignment, token blacklist
86
+ # fail-closed, race conditions, session binding, and path traversal
87
+ )
85
88
 
86
89
  __all__ = [
87
90
  # Core Engine
@@ -86,16 +86,95 @@ def _get_request_path(request: Request) -> str:
86
86
  """
87
87
  Get the request path relative to the mount point.
88
88
 
89
- For mounted apps (via create_multi_app), use request.scope["path"] which
90
- contains the path relative to the mount point. For non-mounted apps,
91
- fall back to request.url.path.
89
+ For mounted apps (via create_multi_app), strips the path prefix from
90
+ request.url.path using request.state.app_base_path. For non-mounted apps,
91
+ uses request.scope["path"] if available, otherwise falls back to request.url.path.
92
92
 
93
93
  This ensures public routes in manifests (which are relative paths like "/")
94
94
  match correctly when apps are mounted at prefixes like "/auth-hub".
95
+
96
+ SECURITY: Normalizes and validates paths to prevent path traversal attacks.
97
+ """
98
+
99
+ # Check if this is a mounted app with a path prefix
100
+ app_base_path = getattr(request.state, "app_base_path", None)
101
+ # Ensure app_base_path is a string (not a MagicMock in tests)
102
+ if app_base_path and isinstance(app_base_path, str):
103
+ # Ensure request.url.path is a string before calling startswith
104
+ url_path = str(request.url.path) if hasattr(request.url, "path") else None
105
+ if url_path and url_path.startswith(app_base_path):
106
+ # Strip the path prefix to get relative path
107
+ relative_path = url_path[len(app_base_path) :]
108
+ # Normalize and sanitize path to prevent traversal attacks
109
+ relative_path = _normalize_path(relative_path)
110
+ # Ensure path starts with / (handle case where prefix is entire path)
111
+ return relative_path if relative_path else "/"
112
+
113
+ # Fall back to scope["path"] for mounted apps (if available)
114
+ # This handles cases where Starlette/FastAPI sets it correctly
115
+ if "path" in request.scope:
116
+ return _normalize_path(request.scope["path"])
117
+
118
+ # Default to url.path for non-mounted apps
119
+ # Ensure we return a string
120
+ if hasattr(request.url, "path"):
121
+ return _normalize_path(str(request.url.path))
122
+ return "/"
123
+
124
+
125
+ def _normalize_path(path: str) -> str:
126
+ """
127
+ Normalize and sanitize a path to prevent path traversal attacks.
128
+
129
+ Args:
130
+ path: Raw path string
131
+
132
+ Returns:
133
+ Normalized path starting with /
95
134
  """
96
- # Use scope["path"] which is relative to mount point for mounted apps
97
- # Fall back to url.path for non-mounted apps
98
- return request.scope.get("path", request.url.path)
135
+ from pathlib import PurePath
136
+ from urllib.parse import unquote
137
+
138
+ if not path:
139
+ return "/"
140
+
141
+ # Preserve trailing slash (except for root)
142
+ has_trailing_slash = path.endswith("/") and path != "/"
143
+
144
+ # Decode URL encoding
145
+ try:
146
+ decoded = unquote(path)
147
+ except (ValueError, UnicodeDecodeError):
148
+ # If decoding fails, use original path
149
+ decoded = path
150
+
151
+ # Normalize path separators and resolve relative components
152
+ try:
153
+ # Use PurePath to normalize without accessing filesystem
154
+ normalized = PurePath(decoded).as_posix()
155
+ except (ValueError, TypeError):
156
+ # If normalization fails, use decoded path
157
+ normalized = decoded
158
+
159
+ # Reject path traversal attempts
160
+ if ".." in normalized or normalized.startswith("/") and normalized != "/":
161
+ # Check if it's a legitimate absolute path (starts with /)
162
+ if normalized.startswith("/") and ".." not in normalized:
163
+ # Valid absolute path
164
+ pass
165
+ else:
166
+ logger.warning(f"Path traversal attempt detected: {path} -> {normalized}")
167
+ return "/" # Return root path for safety
168
+
169
+ # Ensure path starts with /
170
+ if not normalized.startswith("/"):
171
+ normalized = "/" + normalized
172
+
173
+ # Restore trailing slash if it was present (except for root)
174
+ if has_trailing_slash and normalized != "/" and not normalized.endswith("/"):
175
+ normalized = normalized + "/"
176
+
177
+ return normalized
99
178
 
100
179
 
101
180
  class SharedAuthMiddleware(BaseHTTPMiddleware):
@@ -123,6 +202,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
123
202
  public_routes: list[str] | None = None,
124
203
  role_hierarchy: dict[str, list[str]] | None = None,
125
204
  session_binding: dict[str, Any] | None = None,
205
+ auto_assign_default_role: bool = False,
126
206
  cookie_name: str = AUTH_COOKIE_NAME,
127
207
  header_name: str = AUTH_HEADER_NAME,
128
208
  header_prefix: str = AUTH_HEADER_PREFIX,
@@ -142,6 +222,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
142
222
  - bind_ip: Strict - reject if IP changes
143
223
  - bind_fingerprint: Soft - log warning if fingerprint changes
144
224
  - allow_ip_change_with_reauth: Allow IP change on re-authentication
225
+ auto_assign_default_role: If True, automatically assign require_role to users
226
+ with no roles for this app (default: False).
227
+ SECURITY: Only enable if explicitly needed - requires
228
+ default_role in manifest to match require_role.
145
229
  cookie_name: Name of auth cookie (default: mdb_auth_token)
146
230
  header_name: Name of auth header (default: Authorization)
147
231
  header_prefix: Prefix for header value (default: "Bearer ")
@@ -153,6 +237,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
153
237
  self._public_routes = public_routes or []
154
238
  self._role_hierarchy = role_hierarchy
155
239
  self._session_binding = session_binding or {}
240
+ self._auto_assign_default_role = auto_assign_default_role
156
241
  self._cookie_name = cookie_name
157
242
  self._header_name = header_name
158
243
  self._header_prefix = header_prefix
@@ -230,11 +315,10 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
230
315
  )
231
316
 
232
317
  if not has_required_role:
233
- # Auto-assign required role if user has no roles for this app
234
- # This is a fallback for SSO scenarios where users might be authenticated
235
- # but not yet assigned roles. Only do this if they have NO roles (not if
236
- # they have other roles but not the required one - prevents privilege escalation).
237
- if not user_roles:
318
+ # Auto-assign required role ONLY if explicitly enabled and user has no roles
319
+ # SECURITY: This is opt-in to prevent privilege escalation. Only enable if
320
+ # explicitly needed and default_role matches require_role in manifest.
321
+ if not user_roles and self._auto_assign_default_role:
238
322
  user_email = user.get("email")
239
323
  if user_email:
240
324
  try:
@@ -250,7 +334,8 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
250
334
  request.state.user_roles = [self._require_role]
251
335
  logger.info(
252
336
  f"Auto-assigned role '{self._require_role}' to user "
253
- f"{user_email} for app '{self._app_slug}'"
337
+ f"{user_email} for app '{self._app_slug}' "
338
+ f"(auto_assign_default_role enabled)"
254
339
  )
255
340
  else:
256
341
  logger.warning(
@@ -308,17 +393,29 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
308
393
  logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
309
394
  return "Session bound to different IP address"
310
395
 
311
- # Check fingerprint binding (soft check - just warn)
312
- if self._session_binding.get("bind_fingerprint", True):
396
+ # Check fingerprint binding (strict by default for security)
397
+ bind_fingerprint = self._session_binding.get("bind_fingerprint", True)
398
+ strict_fingerprint = self._session_binding.get(
399
+ "strict_fingerprint", True
400
+ ) # Default: strict
401
+ if bind_fingerprint:
313
402
  token_fp = payload.get("fp")
314
403
  if token_fp:
315
404
  client_fp = _compute_fingerprint(request)
316
405
  if client_fp != token_fp:
317
- logger.warning(
318
- f"Session fingerprint mismatch for user {payload.get('email')}"
319
- )
320
- # Soft check - don't reject, just log
321
- # Could be legitimate (browser update, different device)
406
+ if strict_fingerprint:
407
+ logger.warning(
408
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
409
+ f"rejecting request (strict_fingerprint=True)"
410
+ )
411
+ return "Session bound to different device/fingerprint"
412
+ else:
413
+ logger.warning(
414
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
415
+ f"allowing (strict_fingerprint=False)"
416
+ )
417
+ # Soft check - don't reject, just log
418
+ # Could be legitimate (browser update, different device)
322
419
 
323
420
  return None
324
421
 
@@ -402,6 +499,17 @@ def create_shared_auth_middleware(
402
499
  """
403
500
  require_role = manifest_auth.get("require_role")
404
501
  public_routes = manifest_auth.get("public_routes", [])
502
+ auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
503
+ default_role = manifest_auth.get("default_role")
504
+
505
+ # Security: Only allow auto-assignment if default_role matches require_role
506
+ if auto_assign_default_role and require_role and default_role != require_role:
507
+ logger.warning(
508
+ f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
509
+ f"does not match require_role '{require_role}' for app '{app_slug}'. "
510
+ f"Auto-assignment disabled for security."
511
+ )
512
+ auto_assign_default_role = False
405
513
 
406
514
  # Build role hierarchy from manifest if available
407
515
  role_hierarchy = None
@@ -424,6 +532,7 @@ def create_shared_auth_middleware(
424
532
  require_role=require_role,
425
533
  public_routes=public_routes,
426
534
  role_hierarchy=role_hierarchy,
535
+ auto_assign_default_role=auto_assign_default_role,
427
536
  )
428
537
 
429
538
  return ConfiguredSharedAuthMiddleware
@@ -484,12 +593,13 @@ def _extract_token_helper(
484
593
  return None
485
594
 
486
595
 
487
- def _create_lazy_middleware_class(
596
+ def _create_lazy_middleware_class( # noqa: C901
488
597
  app_slug: str,
489
598
  require_role: str | None,
490
599
  public_routes: list[str],
491
600
  role_hierarchy: dict[str, list[str]] | None,
492
601
  session_binding: dict[str, Any],
602
+ auto_assign_default_role: bool = False,
493
603
  ) -> type:
494
604
  """Create the LazySharedAuthMiddleware class with configuration."""
495
605
 
@@ -508,6 +618,7 @@ def _create_lazy_middleware_class(
508
618
  self._public_routes = public_routes
509
619
  self._role_hierarchy = role_hierarchy
510
620
  self._session_binding = session_binding
621
+ self._auto_assign_default_role = auto_assign_default_role
511
622
  self._cookie_name = AUTH_COOKIE_NAME
512
623
  self._header_name = AUTH_HEADER_NAME
513
624
  self._header_prefix = AUTH_HEADER_PREFIX
@@ -662,8 +773,9 @@ def _create_lazy_middleware_class(
662
773
  if has_required_role:
663
774
  return None
664
775
 
665
- # Auto-assign required role if user has no roles for this app
666
- if not user_roles:
776
+ # Auto-assign required role ONLY if explicitly enabled and user has no roles
777
+ # SECURITY: This is opt-in to prevent privilege escalation
778
+ if not user_roles and self._auto_assign_default_role:
667
779
  await self._try_auto_assign_role(user, user_pool, request)
668
780
 
669
781
  # Check again after potential auto-assignment
@@ -688,10 +800,8 @@ def _create_lazy_middleware_class(
688
800
  """
689
801
  Attempt to auto-assign required role to user.
690
802
 
691
- This is a fallback for SSO scenarios where users might be authenticated
692
- but not yet assigned roles. Only do this if they have NO roles (not if
693
- they have other roles but not the required one - prevents privilege
694
- escalation).
803
+ SECURITY: Only called if auto_assign_default_role is enabled and user has
804
+ no roles. This prevents privilege escalation.
695
805
  """
696
806
  user_email = user.get("email")
697
807
  if not user_email:
@@ -710,7 +820,8 @@ def _create_lazy_middleware_class(
710
820
  request.state.user_roles = [self._require_role]
711
821
  logger.info(
712
822
  f"Auto-assigned role '{self._require_role}' to user "
713
- f"{user_email} for app '{self._app_slug}'"
823
+ f"{user_email} for app '{self._app_slug}' "
824
+ f"(auto_assign_default_role enabled)"
714
825
  )
715
826
  else:
716
827
  logger.warning(
@@ -750,8 +861,10 @@ def _create_lazy_middleware_class(
750
861
  if ip_error:
751
862
  return ip_error
752
863
 
753
- # Check fingerprint binding (soft check - just warn)
754
- self._check_fingerprint_binding(request, payload)
864
+ # Check fingerprint binding (strict by default)
865
+ fingerprint_error = await self._check_fingerprint_binding(request, payload)
866
+ if fingerprint_error:
867
+ return fingerprint_error
755
868
 
756
869
  return None
757
870
 
@@ -775,19 +888,38 @@ def _create_lazy_middleware_class(
775
888
 
776
889
  return None
777
890
 
778
- def _check_fingerprint_binding(self, request: Request, payload: dict) -> None:
779
- """Check fingerprint binding from token payload (soft check - just warn)."""
891
+ async def _check_fingerprint_binding(self, request: Request, payload: dict) -> str | None:
892
+ """
893
+ Check fingerprint binding from token payload.
894
+
895
+ Returns error message if validation fails, None if OK.
896
+ """
780
897
  if not self._session_binding.get("bind_fingerprint", True):
781
- return
898
+ return None
782
899
 
783
900
  token_fp = payload.get("fp")
784
901
  if not token_fp:
785
- return
902
+ return None
786
903
 
904
+ strict_fingerprint = self._session_binding.get(
905
+ "strict_fingerprint", True
906
+ ) # Default: strict
787
907
  client_fp = _compute_fingerprint(request)
788
908
  if client_fp != token_fp:
789
- logger.warning(f"Session fingerprint mismatch for user {payload.get('email')}")
790
- # Soft check - don't reject, just log
909
+ if strict_fingerprint:
910
+ logger.warning(
911
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
912
+ f"rejecting request (strict_fingerprint=True)"
913
+ )
914
+ return "Session bound to different device/fingerprint"
915
+ else:
916
+ logger.warning(
917
+ f"Session fingerprint mismatch for user {payload.get('email')} - "
918
+ f"allowing (strict_fingerprint=False)"
919
+ )
920
+ # Soft check - don't reject, just log
921
+ return None
922
+ return None
791
923
 
792
924
  return LazySharedAuthMiddleware
793
925
 
@@ -820,9 +952,26 @@ def create_shared_auth_middleware_lazy(
820
952
  """
821
953
  require_role = manifest_auth.get("require_role")
822
954
  public_routes = manifest_auth.get("public_routes", [])
955
+ auto_assign_default_role = manifest_auth.get("auto_assign_default_role", False)
956
+ default_role = manifest_auth.get("default_role")
957
+
958
+ # Security: Only allow auto-assignment if default_role matches require_role
959
+ if auto_assign_default_role and require_role and default_role != require_role:
960
+ logger.warning(
961
+ f"Security: auto_assign_default_role enabled but default_role '{default_role}' "
962
+ f"does not match require_role '{require_role}' for app '{app_slug}'. "
963
+ f"Auto-assignment disabled for security."
964
+ )
965
+ auto_assign_default_role = False
966
+
823
967
  role_hierarchy = _build_role_hierarchy(manifest_auth)
824
968
  session_binding = manifest_auth.get("session_binding", {})
825
969
 
826
970
  return _create_lazy_middleware_class(
827
- app_slug, require_role, public_routes, role_hierarchy, session_binding
971
+ app_slug,
972
+ require_role,
973
+ public_routes,
974
+ role_hierarchy,
975
+ session_binding,
976
+ auto_assign_default_role,
828
977
  )
@@ -119,6 +119,7 @@ class SharedUserPool:
119
119
  jwt_algorithm: str = DEFAULT_JWT_ALGORITHM,
120
120
  token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
121
121
  allow_insecure_dev: bool = False,
122
+ blacklist_fail_closed: bool = True,
122
123
  ):
123
124
  """
124
125
  Initialize the shared user pool.
@@ -138,6 +139,9 @@ class SharedUserPool:
138
139
  token_expiry_hours: Token expiry in hours (default: 24)
139
140
  allow_insecure_dev: Allow insecure auto-generated secret for local
140
141
  development only. NEVER use in production!
142
+ blacklist_fail_closed: If True (default), reject tokens when blacklist check
143
+ fails (secure). If False, allow tokens when check fails
144
+ (availability). SECURITY: Default is True for security.
141
145
 
142
146
  Raises:
143
147
  JWTSecretError: If no JWT secret is provided and allow_insecure_dev=False
@@ -146,6 +150,7 @@ class SharedUserPool:
146
150
  self._db = mongo_db
147
151
  self._collection = mongo_db[SHARED_USERS_COLLECTION]
148
152
  self._blacklist_collection = mongo_db[TOKEN_BLACKLIST_COLLECTION]
153
+ self._blacklist_fail_closed = blacklist_fail_closed
149
154
 
150
155
  # Validate algorithm
151
156
  if jwt_algorithm not in SUPPORTED_ALGORITHMS:
@@ -453,8 +458,19 @@ class SharedUserPool:
453
458
  return False
454
459
  except PyMongoError as e:
455
460
  logger.exception(f"Error checking token blacklist: {e}")
456
- # Fail open for availability (can be changed to fail closed for security)
457
- return False
461
+ # Fail closed for security by default (reject token if we can't verify)
462
+ if self._blacklist_fail_closed:
463
+ logger.warning(
464
+ "Token blacklist check failed - rejecting token for security "
465
+ "(blacklist_fail_closed=True)"
466
+ )
467
+ return True # Token IS revoked (reject access)
468
+ # Fail open only if explicitly configured (availability over security)
469
+ logger.warning(
470
+ "Token blacklist check failed - allowing token for availability "
471
+ "(blacklist_fail_closed=False). SECURITY RISK: Revoked tokens may be accepted."
472
+ )
473
+ return False # Token NOT revoked (allow access)
458
474
 
459
475
  async def revoke_token(
460
476
  self,
@@ -155,6 +155,12 @@ class MongoDBEngine:
155
155
  # Store app token cache for auto-retrieval
156
156
  self._app_token_cache: dict[str, str] = {}
157
157
 
158
+ # Async lock for thread-safe shared user pool initialization
159
+ import asyncio
160
+
161
+ self._shared_user_pool_lock = asyncio.Lock()
162
+ self._shared_user_pool_initializing = False
163
+
158
164
  async def initialize(self) -> None:
159
165
  """
160
166
  Initialize the MongoDB Engine.
@@ -1960,9 +1966,26 @@ class MongoDBEngine:
1960
1966
  )
1961
1967
 
1962
1968
  # State for parent app
1963
- mounted_apps: list[dict[str, Any]] = []
1969
+ # Build initial mounted_apps metadata synchronously so get_mounted_apps() works
1970
+ # immediately after create_multi_app() returns (before lifespan runs)
1971
+ mounted_apps: list[dict[str, Any]] = [
1972
+ {
1973
+ "slug": app_config["slug"],
1974
+ "path_prefix": app_config["path_prefix"],
1975
+ "status": "pending", # Will be updated in lifespan to "mounted" or "failed"
1976
+ "manifest_path": str(app_config["manifest"]),
1977
+ }
1978
+ for app_config in apps
1979
+ ]
1964
1980
  shared_user_pool_initialized = False
1965
1981
 
1982
+ def _find_mounted_app_entry(slug: str) -> dict[str, Any] | None:
1983
+ """Find mounted app entry by slug."""
1984
+ for entry in mounted_apps:
1985
+ if entry.get("slug") == slug:
1986
+ return entry
1987
+ return None
1988
+
1966
1989
  @asynccontextmanager
1967
1990
  async def lifespan(app: FastAPI):
1968
1991
  """Lifespan context manager for parent app."""
@@ -2133,14 +2156,25 @@ class MongoDBEngine:
2133
2156
 
2134
2157
  # Mount child app at path prefix
2135
2158
  app.mount(path_prefix, child_app)
2136
- mounted_apps.append(
2137
- {
2138
- "slug": slug,
2139
- "path_prefix": path_prefix,
2140
- "status": "mounted",
2141
- "manifest": app_manifest_data,
2142
- }
2143
- )
2159
+ # Update existing entry instead of appending
2160
+ entry = _find_mounted_app_entry(slug)
2161
+ if entry:
2162
+ entry.update(
2163
+ {
2164
+ "status": "mounted",
2165
+ "manifest": app_manifest_data,
2166
+ }
2167
+ )
2168
+ else:
2169
+ # Fallback: append if entry not found (shouldn't happen)
2170
+ mounted_apps.append(
2171
+ {
2172
+ "slug": slug,
2173
+ "path_prefix": path_prefix,
2174
+ "status": "mounted",
2175
+ "manifest": app_manifest_data,
2176
+ }
2177
+ )
2144
2178
  logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
2145
2179
 
2146
2180
  except FileNotFoundError as e:
@@ -2149,15 +2183,26 @@ class MongoDBEngine:
2149
2183
  f"manifest.json not found at {manifest_path}"
2150
2184
  )
2151
2185
  logger.error(error_msg, exc_info=True)
2152
- mounted_apps.append(
2153
- {
2154
- "slug": slug,
2155
- "path_prefix": path_prefix,
2156
- "status": "failed",
2157
- "error": error_msg,
2158
- "manifest_path": str(manifest_path),
2159
- }
2160
- )
2186
+ # Update existing entry instead of appending
2187
+ entry = _find_mounted_app_entry(slug)
2188
+ if entry:
2189
+ entry.update(
2190
+ {
2191
+ "status": "failed",
2192
+ "error": error_msg,
2193
+ }
2194
+ )
2195
+ else:
2196
+ # Fallback: append if entry not found (shouldn't happen)
2197
+ mounted_apps.append(
2198
+ {
2199
+ "slug": slug,
2200
+ "path_prefix": path_prefix,
2201
+ "status": "failed",
2202
+ "error": error_msg,
2203
+ "manifest_path": str(manifest_path),
2204
+ }
2205
+ )
2161
2206
  if strict:
2162
2207
  raise ValueError(error_msg) from e
2163
2208
  continue
@@ -2167,71 +2212,111 @@ class MongoDBEngine:
2167
2212
  f"Invalid JSON in manifest.json at {manifest_path}: {e}"
2168
2213
  )
2169
2214
  logger.error(error_msg, exc_info=True)
2170
- mounted_apps.append(
2171
- {
2172
- "slug": slug,
2173
- "path_prefix": path_prefix,
2174
- "status": "failed",
2175
- "error": error_msg,
2176
- "manifest_path": str(manifest_path),
2177
- }
2178
- )
2215
+ # Update existing entry instead of appending
2216
+ entry = _find_mounted_app_entry(slug)
2217
+ if entry:
2218
+ entry.update(
2219
+ {
2220
+ "status": "failed",
2221
+ "error": error_msg,
2222
+ }
2223
+ )
2224
+ else:
2225
+ # Fallback: append if entry not found (shouldn't happen)
2226
+ mounted_apps.append(
2227
+ {
2228
+ "slug": slug,
2229
+ "path_prefix": path_prefix,
2230
+ "status": "failed",
2231
+ "error": error_msg,
2232
+ "manifest_path": str(manifest_path),
2233
+ }
2234
+ )
2179
2235
  if strict:
2180
2236
  raise ValueError(error_msg) from e
2181
2237
  continue
2182
2238
  except ValueError as e:
2183
2239
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2184
2240
  logger.error(error_msg, exc_info=True)
2185
- mounted_apps.append(
2186
- {
2187
- "slug": slug,
2188
- "path_prefix": path_prefix,
2189
- "status": "failed",
2190
- "error": error_msg,
2191
- "manifest_path": str(manifest_path),
2192
- }
2193
- )
2241
+ # Update existing entry instead of appending
2242
+ entry = _find_mounted_app_entry(slug)
2243
+ if entry:
2244
+ entry.update(
2245
+ {
2246
+ "status": "failed",
2247
+ "error": error_msg,
2248
+ }
2249
+ )
2250
+ else:
2251
+ # Fallback: append if entry not found (shouldn't happen)
2252
+ mounted_apps.append(
2253
+ {
2254
+ "slug": slug,
2255
+ "path_prefix": path_prefix,
2256
+ "status": "failed",
2257
+ "error": error_msg,
2258
+ "manifest_path": str(manifest_path),
2259
+ }
2260
+ )
2194
2261
  if strict:
2195
2262
  raise ValueError(error_msg) from e
2196
2263
  continue
2197
2264
  except (KeyError, RuntimeError) as e:
2198
2265
  error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2199
2266
  logger.error(error_msg, exc_info=True)
2200
- mounted_apps.append(
2201
- {
2202
- "slug": slug,
2203
- "path_prefix": path_prefix,
2204
- "status": "failed",
2205
- "error": error_msg,
2206
- "manifest_path": str(manifest_path),
2207
- }
2208
- )
2267
+ # Update existing entry instead of appending
2268
+ entry = _find_mounted_app_entry(slug)
2269
+ if entry:
2270
+ entry.update(
2271
+ {
2272
+ "status": "failed",
2273
+ "error": error_msg,
2274
+ }
2275
+ )
2276
+ else:
2277
+ # Fallback: append if entry not found (shouldn't happen)
2278
+ mounted_apps.append(
2279
+ {
2280
+ "slug": slug,
2281
+ "path_prefix": path_prefix,
2282
+ "status": "failed",
2283
+ "error": error_msg,
2284
+ "manifest_path": str(manifest_path),
2285
+ }
2286
+ )
2209
2287
  if strict:
2210
2288
  raise RuntimeError(error_msg) from e
2211
2289
  continue
2212
2290
  except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
2213
2291
  error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
2214
2292
  logger.error(error_msg, exc_info=True)
2215
- mounted_apps.append(
2216
- {
2217
- "slug": slug,
2218
- "path_prefix": path_prefix,
2219
- "status": "failed",
2220
- "error": error_msg,
2221
- "manifest_path": str(manifest_path),
2222
- }
2223
- )
2293
+ # Update existing entry instead of appending
2294
+ entry = _find_mounted_app_entry(slug)
2295
+ if entry:
2296
+ entry.update(
2297
+ {
2298
+ "status": "failed",
2299
+ "error": error_msg,
2300
+ }
2301
+ )
2302
+ else:
2303
+ # Fallback: append if entry not found (shouldn't happen)
2304
+ mounted_apps.append(
2305
+ {
2306
+ "slug": slug,
2307
+ "path_prefix": path_prefix,
2308
+ "status": "failed",
2309
+ "error": error_msg,
2310
+ "manifest_path": str(manifest_path),
2311
+ }
2312
+ )
2224
2313
  if strict:
2225
2314
  raise RuntimeError(error_msg) from e
2226
2315
  continue
2227
2316
 
2228
- # Expose engine and mounted apps info on parent app state
2229
- app.state.engine = engine
2317
+ # Update app.state.mounted_apps with final status (entries already updated in place)
2318
+ # This ensures the state reflects the final mounted_apps list
2230
2319
  app.state.mounted_apps = mounted_apps
2231
- app.state.is_multi_app = True
2232
-
2233
- # Store app reference in engine for get_mounted_apps()
2234
- engine._multi_app_instance = app
2235
2320
 
2236
2321
  yield
2237
2322
 
@@ -2241,6 +2326,14 @@ class MongoDBEngine:
2241
2326
  # Create parent FastAPI app
2242
2327
  parent_app = FastAPI(title=title, lifespan=lifespan, root_path=root_path, **fastapi_kwargs)
2243
2328
 
2329
+ # Set mounted_apps immediately so get_mounted_apps() works before lifespan runs
2330
+ parent_app.state.mounted_apps = mounted_apps
2331
+ parent_app.state.is_multi_app = True
2332
+ parent_app.state.engine = engine
2333
+
2334
+ # Store app reference in engine for get_mounted_apps()
2335
+ engine._multi_app_instance = parent_app
2336
+
2244
2337
  # Add request scope middleware
2245
2338
  from starlette.middleware.base import BaseHTTPMiddleware
2246
2339
 
@@ -2629,17 +2722,41 @@ class MongoDBEngine:
2629
2722
  or os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
2630
2723
  )
2631
2724
 
2632
- # Create or get shared user pool
2633
- if not hasattr(self, "_shared_user_pool") or self._shared_user_pool is None:
2634
- self._shared_user_pool = SharedUserPool(
2635
- self._connection_manager.mongo_db,
2636
- allow_insecure_dev=is_dev,
2637
- )
2638
- await self._shared_user_pool.ensure_indexes()
2639
- logger.info("SharedUserPool initialized")
2725
+ # Thread-safe initialization with async lock to prevent race conditions
2726
+ async with self._shared_user_pool_lock:
2727
+ # Check if another coroutine is initializing
2728
+ if self._shared_user_pool_initializing:
2729
+ # Wait for other initialization to complete
2730
+ while self._shared_user_pool_initializing:
2731
+ import asyncio
2732
+
2733
+ await asyncio.sleep(0.01) # Small delay to avoid busy-waiting
2734
+ # After waiting, check if pool was initialized
2735
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2736
+ app.state.user_pool = self._shared_user_pool
2737
+ return
2738
+
2739
+ # Check if already initialized (double-check pattern)
2740
+ if hasattr(self, "_shared_user_pool") and self._shared_user_pool is not None:
2741
+ app.state.user_pool = self._shared_user_pool
2742
+ return
2640
2743
 
2641
- # Expose user pool on app.state for middleware to access
2642
- app.state.user_pool = self._shared_user_pool
2744
+ # Mark as initializing
2745
+ self._shared_user_pool_initializing = True
2746
+ try:
2747
+ # Create shared user pool
2748
+ self._shared_user_pool = SharedUserPool(
2749
+ self._connection_manager.mongo_db,
2750
+ allow_insecure_dev=is_dev,
2751
+ )
2752
+ await self._shared_user_pool.ensure_indexes()
2753
+ logger.info("SharedUserPool initialized")
2754
+
2755
+ # Expose user pool on app.state for middleware to access
2756
+ app.state.user_pool = self._shared_user_pool
2757
+ finally:
2758
+ # Always clear the initializing flag
2759
+ self._shared_user_pool_initializing = False
2643
2760
 
2644
2761
  # Seed demo users to SharedUserPool if configured in manifest
2645
2762
  if manifest:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdb-engine
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: MongoDB Engine
5
5
  Home-page: https://github.com/ranfysvalle02/mdb-engine
6
6
  Author: Fabian Valle
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mdb-engine"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "MongoDB Engine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ if readme_file.exists():
14
14
 
15
15
  setup(
16
16
  name="mdb-engine",
17
- version="0.4.0",
17
+ version="0.4.1",
18
18
  description="MongoDB Engine",
19
19
  long_description=long_description,
20
20
  long_description_content_type="text/markdown",
File without changes
File without changes
File without changes
File without changes