mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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 (92) hide show
  1. mdb_engine/__init__.py +116 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +654 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +265 -70
  8. mdb_engine/auth/config_defaults.py +5 -5
  9. mdb_engine/auth/config_helpers.py +19 -18
  10. mdb_engine/auth/cookie_utils.py +12 -16
  11. mdb_engine/auth/csrf.py +483 -0
  12. mdb_engine/auth/decorators.py +10 -16
  13. mdb_engine/auth/dependencies.py +69 -71
  14. mdb_engine/auth/helpers.py +3 -3
  15. mdb_engine/auth/integration.py +61 -88
  16. mdb_engine/auth/jwt.py +11 -15
  17. mdb_engine/auth/middleware.py +79 -35
  18. mdb_engine/auth/oso_factory.py +21 -41
  19. mdb_engine/auth/provider.py +270 -171
  20. mdb_engine/auth/rate_limiter.py +505 -0
  21. mdb_engine/auth/restrictions.py +21 -36
  22. mdb_engine/auth/session_manager.py +24 -41
  23. mdb_engine/auth/shared_middleware.py +977 -0
  24. mdb_engine/auth/shared_users.py +775 -0
  25. mdb_engine/auth/token_lifecycle.py +10 -12
  26. mdb_engine/auth/token_store.py +17 -32
  27. mdb_engine/auth/users.py +99 -159
  28. mdb_engine/auth/utils.py +236 -42
  29. mdb_engine/cli/commands/generate.py +546 -10
  30. mdb_engine/cli/commands/validate.py +3 -7
  31. mdb_engine/cli/utils.py +7 -7
  32. mdb_engine/config.py +13 -28
  33. mdb_engine/constants.py +65 -0
  34. mdb_engine/core/README.md +117 -6
  35. mdb_engine/core/__init__.py +39 -7
  36. mdb_engine/core/app_registration.py +31 -50
  37. mdb_engine/core/app_secrets.py +289 -0
  38. mdb_engine/core/connection.py +20 -12
  39. mdb_engine/core/encryption.py +222 -0
  40. mdb_engine/core/engine.py +2862 -115
  41. mdb_engine/core/index_management.py +12 -16
  42. mdb_engine/core/manifest.py +628 -204
  43. mdb_engine/core/ray_integration.py +436 -0
  44. mdb_engine/core/seeding.py +13 -21
  45. mdb_engine/core/service_initialization.py +20 -30
  46. mdb_engine/core/types.py +40 -43
  47. mdb_engine/database/README.md +140 -17
  48. mdb_engine/database/__init__.py +17 -6
  49. mdb_engine/database/abstraction.py +37 -50
  50. mdb_engine/database/connection.py +51 -30
  51. mdb_engine/database/query_validator.py +367 -0
  52. mdb_engine/database/resource_limiter.py +204 -0
  53. mdb_engine/database/scoped_wrapper.py +747 -237
  54. mdb_engine/dependencies.py +427 -0
  55. mdb_engine/di/__init__.py +34 -0
  56. mdb_engine/di/container.py +247 -0
  57. mdb_engine/di/providers.py +206 -0
  58. mdb_engine/di/scopes.py +139 -0
  59. mdb_engine/embeddings/README.md +54 -24
  60. mdb_engine/embeddings/__init__.py +31 -24
  61. mdb_engine/embeddings/dependencies.py +38 -155
  62. mdb_engine/embeddings/service.py +78 -75
  63. mdb_engine/exceptions.py +104 -12
  64. mdb_engine/indexes/README.md +30 -13
  65. mdb_engine/indexes/__init__.py +1 -0
  66. mdb_engine/indexes/helpers.py +11 -11
  67. mdb_engine/indexes/manager.py +59 -123
  68. mdb_engine/memory/README.md +95 -4
  69. mdb_engine/memory/__init__.py +1 -2
  70. mdb_engine/memory/service.py +363 -1168
  71. mdb_engine/observability/README.md +4 -2
  72. mdb_engine/observability/__init__.py +26 -9
  73. mdb_engine/observability/health.py +17 -17
  74. mdb_engine/observability/logging.py +10 -10
  75. mdb_engine/observability/metrics.py +40 -19
  76. mdb_engine/repositories/__init__.py +34 -0
  77. mdb_engine/repositories/base.py +325 -0
  78. mdb_engine/repositories/mongo.py +233 -0
  79. mdb_engine/repositories/unit_of_work.py +166 -0
  80. mdb_engine/routing/README.md +1 -1
  81. mdb_engine/routing/__init__.py +1 -3
  82. mdb_engine/routing/websockets.py +41 -75
  83. mdb_engine/utils/__init__.py +3 -1
  84. mdb_engine/utils/mongo.py +117 -0
  85. mdb_engine-0.4.12.dist-info/METADATA +492 -0
  86. mdb_engine-0.4.12.dist-info/RECORD +97 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
  88. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  89. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  90. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
  91. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
  92. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,977 @@
1
+ """
2
+ Shared Auth Middleware for Multi-App SSO
3
+
4
+ ASGI middleware that handles authentication for apps using "shared" auth mode.
5
+ Automatically validates JWT tokens and populates request.state with user info.
6
+
7
+ This module is part of MDB_ENGINE - MongoDB Engine.
8
+
9
+ Usage (auto-configured by engine.create_app() when auth.mode="shared"):
10
+ # Middleware is automatically added when manifest has auth.mode="shared"
11
+
12
+ # Access user in route handlers:
13
+ @app.get("/protected")
14
+ async def protected(request: Request):
15
+ user = request.state.user # None if not authenticated
16
+ if not user:
17
+ raise HTTPException(status_code=401)
18
+ return {"email": user["email"]}
19
+
20
+ Manual usage:
21
+ from mdb_engine.auth import SharedAuthMiddleware, SharedUserPool
22
+
23
+ pool = SharedUserPool(db)
24
+ app.add_middleware(
25
+ SharedAuthMiddleware,
26
+ user_pool=pool,
27
+ require_role="viewer",
28
+ public_routes=["/health", "/api/public/*"],
29
+ )
30
+ """
31
+
32
+ import fnmatch
33
+ import hashlib
34
+ import logging
35
+ from collections.abc import Callable
36
+ from typing import Any
37
+
38
+ import jwt
39
+ from pymongo.errors import PyMongoError
40
+ from starlette.middleware.base import BaseHTTPMiddleware
41
+ from starlette.requests import Request
42
+ from starlette.responses import JSONResponse, Response
43
+
44
+ from .shared_users import SharedUserPool
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Cookie and header names for JWT token
49
+ AUTH_COOKIE_NAME = "mdb_auth_token"
50
+ AUTH_HEADER_NAME = "Authorization"
51
+ AUTH_HEADER_PREFIX = "Bearer "
52
+
53
+
54
+ def _get_client_ip(request: Request) -> str | None:
55
+ """Extract client IP address from request, handling proxies."""
56
+ # Check X-Forwarded-For header (behind load balancer/proxy)
57
+ forwarded_for = request.headers.get("x-forwarded-for")
58
+ if forwarded_for:
59
+ # Take the first IP (original client)
60
+ return forwarded_for.split(",")[0].strip()
61
+
62
+ # Check X-Real-IP header
63
+ real_ip = request.headers.get("x-real-ip")
64
+ if real_ip:
65
+ return real_ip
66
+
67
+ # Fall back to direct client
68
+ if request.client:
69
+ return request.client.host
70
+
71
+ return None
72
+
73
+
74
+ def _compute_fingerprint(request: Request) -> str:
75
+ """Compute a device fingerprint from request characteristics."""
76
+ components = [
77
+ request.headers.get("user-agent", ""),
78
+ request.headers.get("accept-language", ""),
79
+ request.headers.get("accept-encoding", ""),
80
+ ]
81
+ fingerprint_string = "|".join(components)
82
+ return hashlib.sha256(fingerprint_string.encode()).hexdigest()
83
+
84
+
85
+ def _get_request_path(request: Request) -> str:
86
+ """
87
+ Get the request path relative to the mount point.
88
+
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
+
93
+ This ensures public routes in manifests (which are relative paths like "/")
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 /
134
+ """
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
178
+
179
+
180
+ class SharedAuthMiddleware(BaseHTTPMiddleware):
181
+ """
182
+ Middleware for shared authentication across multi-app deployments.
183
+
184
+ Features:
185
+ - Reads JWT from cookie or Authorization header
186
+ - Validates token and populates request.state.user
187
+ - Checks role requirements if configured
188
+ - Skips authentication for public routes
189
+ - Returns 401/403 JSON responses for auth failures
190
+
191
+ The middleware sets:
192
+ - request.state.user: Dict with user info (or None if not authenticated)
193
+ - request.state.user_roles: List of user's roles for current app
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ app: Callable,
199
+ user_pool: SharedUserPool | None,
200
+ app_slug: str,
201
+ require_role: str | None = None,
202
+ public_routes: list[str] | None = None,
203
+ role_hierarchy: dict[str, list[str]] | None = None,
204
+ session_binding: dict[str, Any] | None = None,
205
+ auto_assign_default_role: bool = False,
206
+ cookie_name: str = AUTH_COOKIE_NAME,
207
+ header_name: str = AUTH_HEADER_NAME,
208
+ header_prefix: str = AUTH_HEADER_PREFIX,
209
+ ):
210
+ """
211
+ Initialize shared auth middleware.
212
+
213
+ Args:
214
+ app: ASGI application
215
+ user_pool: SharedUserPool instance (optional for lazy loading)
216
+ app_slug: Current app's slug (for role checking)
217
+ require_role: Role required to access this app (None = no role check)
218
+ public_routes: List of route patterns that don't require auth.
219
+ Supports wildcards, e.g., ["/health", "/api/public/*"]
220
+ role_hierarchy: Optional role hierarchy for inheritance
221
+ session_binding: Session binding configuration:
222
+ - bind_ip: Strict - reject if IP changes
223
+ - bind_fingerprint: Soft - log warning if fingerprint changes
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.
229
+ cookie_name: Name of auth cookie (default: mdb_auth_token)
230
+ header_name: Name of auth header (default: Authorization)
231
+ header_prefix: Prefix for header value (default: "Bearer ")
232
+ """
233
+ super().__init__(app)
234
+ self._user_pool = user_pool
235
+ self._app_slug = app_slug
236
+ self._require_role = require_role
237
+ self._public_routes = public_routes or []
238
+ self._role_hierarchy = role_hierarchy
239
+ self._session_binding = session_binding or {}
240
+ self._auto_assign_default_role = auto_assign_default_role
241
+ self._cookie_name = cookie_name
242
+ self._header_name = header_name
243
+ self._header_prefix = header_prefix
244
+
245
+ logger.info(
246
+ f"SharedAuthMiddleware initialized for '{app_slug}' "
247
+ f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
248
+ f"session_binding={bool(self._session_binding)})"
249
+ )
250
+
251
+ def get_user_pool(self, request: Request) -> SharedUserPool | None:
252
+ """Get the user pool instance. Override in subclasses for lazy loading."""
253
+ return self._user_pool
254
+
255
+ async def dispatch(
256
+ self,
257
+ request: Request,
258
+ call_next: Callable[[Request], Response],
259
+ ) -> Response:
260
+ """Process request through auth middleware."""
261
+ # Initialize request state
262
+ request.state.user = None
263
+ request.state.user_roles = []
264
+
265
+ # Get user pool
266
+ user_pool = self.get_user_pool(request)
267
+ if not user_pool:
268
+ # User pool not available (e.g., lazy loading failed), skip auth if not strict
269
+ # But here we default to skipping for robustness if pool is missing
270
+ # However, for Lazy middleware, we want to skip if not initialized yet
271
+ return await call_next(request)
272
+
273
+ is_public = self._is_public_route(_get_request_path(request))
274
+
275
+ # Extract token from cookie or header
276
+ token = self._extract_token(request)
277
+
278
+ if not token:
279
+ # No token provided
280
+ if not is_public and self._require_role:
281
+ return self._unauthorized_response("Authentication required")
282
+ # No role required or public route, continue without user
283
+ return await call_next(request)
284
+
285
+ # Validate token and get user
286
+ user = await user_pool.validate_token(token)
287
+
288
+ if not user:
289
+ # Invalid token - for public routes, continue without user
290
+ if is_public:
291
+ return await call_next(request)
292
+ return self._unauthorized_response("Invalid or expired token")
293
+
294
+ # Validate session binding if configured
295
+ binding_error = await self._validate_session_binding(request, token)
296
+ if binding_error:
297
+ if is_public:
298
+ # For public routes, log but continue
299
+ logger.warning(f"Session binding mismatch on public route: {binding_error}")
300
+ else:
301
+ return self._forbidden_response(binding_error)
302
+
303
+ # Set user on request state
304
+ request.state.user = user
305
+ request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
306
+
307
+ # Check role requirement (only for non-public routes)
308
+ if not is_public and self._require_role:
309
+ user_roles = request.state.user_roles
310
+ has_required_role = SharedUserPool.user_has_role(
311
+ user,
312
+ self._app_slug,
313
+ self._require_role,
314
+ self._role_hierarchy,
315
+ )
316
+
317
+ if not has_required_role:
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:
322
+ user_email = user.get("email")
323
+ if user_email:
324
+ try:
325
+ # Auto-assign the required role
326
+ success = await user_pool.update_user_roles(
327
+ user_email, self._app_slug, [self._require_role]
328
+ )
329
+ if success:
330
+ # Refresh user data to include new role
331
+ user = await user_pool.get_user_by_email(user_email)
332
+ if user:
333
+ request.state.user = user
334
+ request.state.user_roles = [self._require_role]
335
+ logger.info(
336
+ f"Auto-assigned role '{self._require_role}' to user "
337
+ f"{user_email} for app '{self._app_slug}' "
338
+ f"(auto_assign_default_role enabled)"
339
+ )
340
+ else:
341
+ logger.warning(
342
+ f"Failed to refresh user after auto-assigning role: "
343
+ f"{user_email}"
344
+ )
345
+ else:
346
+ logger.warning(
347
+ f"Failed to auto-assign role '{self._require_role}' to "
348
+ f"user {user_email} for app '{self._app_slug}'"
349
+ )
350
+ except (PyMongoError, ValueError, AttributeError) as e:
351
+ logger.error(
352
+ f"Error auto-assigning role to user {user_email}: {e}",
353
+ exc_info=True,
354
+ )
355
+
356
+ # Check again after potential auto-assignment
357
+ if not SharedUserPool.user_has_role(
358
+ user,
359
+ self._app_slug,
360
+ self._require_role,
361
+ self._role_hierarchy,
362
+ ):
363
+ return self._forbidden_response(
364
+ f"Role '{self._require_role}' required for this app"
365
+ )
366
+
367
+ return await call_next(request)
368
+
369
+ async def _validate_session_binding(
370
+ self,
371
+ request: Request,
372
+ token: str,
373
+ ) -> str | None:
374
+ """
375
+ Validate session binding claims in token.
376
+
377
+ Returns error message if validation fails, None if OK.
378
+ """
379
+ if not self._session_binding:
380
+ return None
381
+
382
+ try:
383
+ # Decode token without verification to get claims
384
+ # (verification already done in validate_token)
385
+ payload = jwt.decode(token, options={"verify_signature": False})
386
+
387
+ # Check IP binding
388
+ if self._session_binding.get("bind_ip", False):
389
+ token_ip = payload.get("ip")
390
+ if token_ip:
391
+ client_ip = _get_client_ip(request)
392
+ if client_ip and client_ip != token_ip:
393
+ logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
394
+ return "Session bound to different IP address"
395
+
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:
402
+ token_fp = payload.get("fp")
403
+ if token_fp:
404
+ client_fp = _compute_fingerprint(request)
405
+ if client_fp != token_fp:
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)
419
+
420
+ return None
421
+
422
+ except jwt.InvalidTokenError as e:
423
+ logger.warning(f"Error validating session binding: {e}")
424
+ return None # Don't reject for binding check errors
425
+
426
+ def _extract_token(self, request: Request) -> str | None:
427
+ """Extract JWT token from cookie or header."""
428
+ # Try cookie first
429
+ token = request.cookies.get(self._cookie_name)
430
+ if token:
431
+ return token
432
+
433
+ # Try Authorization header
434
+ auth_header = request.headers.get(self._header_name)
435
+ if auth_header and auth_header.startswith(self._header_prefix):
436
+ return auth_header[len(self._header_prefix) :]
437
+
438
+ return None
439
+
440
+ def _is_public_route(self, path: str) -> bool:
441
+ """Check if path matches any public route pattern."""
442
+ for pattern in self._public_routes:
443
+ # Normalize pattern for fnmatch
444
+ if not pattern.startswith("/"):
445
+ pattern = "/" + pattern
446
+
447
+ # Check exact match
448
+ if path == pattern:
449
+ return True
450
+
451
+ # Check wildcard match
452
+ if fnmatch.fnmatch(path, pattern):
453
+ return True
454
+
455
+ # Check prefix match for patterns ending with /*
456
+ if pattern.endswith("/*"):
457
+ prefix = pattern[:-2]
458
+ if path.startswith(prefix):
459
+ return True
460
+
461
+ return False
462
+
463
+ @staticmethod
464
+ def _unauthorized_response(detail: str) -> JSONResponse:
465
+ """Return 401 Unauthorized response."""
466
+ return JSONResponse(
467
+ status_code=401,
468
+ content={"detail": detail, "error": "unauthorized"},
469
+ )
470
+
471
+ @staticmethod
472
+ def _forbidden_response(detail: str) -> JSONResponse:
473
+ """Return 403 Forbidden response."""
474
+ return JSONResponse(
475
+ status_code=403,
476
+ content={"detail": detail, "error": "forbidden"},
477
+ )
478
+
479
+
480
+ def create_shared_auth_middleware(
481
+ user_pool: SharedUserPool,
482
+ app_slug: str,
483
+ manifest_auth: dict[str, Any],
484
+ ) -> type:
485
+ """
486
+ Factory function to create SharedAuthMiddleware configured from manifest.
487
+
488
+ Args:
489
+ user_pool: SharedUserPool instance
490
+ app_slug: Current app's slug
491
+ manifest_auth: Auth section from manifest
492
+
493
+ Returns:
494
+ Configured middleware class ready to add to FastAPI app
495
+
496
+ Usage:
497
+ middleware_class = create_shared_auth_middleware(pool, "my_app", manifest["auth"])
498
+ app.add_middleware(middleware_class)
499
+ """
500
+ require_role = manifest_auth.get("require_role")
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
513
+
514
+ # Build role hierarchy from manifest if available
515
+ role_hierarchy = None
516
+ roles = manifest_auth.get("roles", [])
517
+ if roles and len(roles) > 1:
518
+ # Auto-generate hierarchy: each role inherits from roles below it
519
+ # e.g., roles=["viewer", "editor", "admin"] -> admin > editor > viewer
520
+ role_hierarchy = {}
521
+ for i, role in enumerate(roles):
522
+ if i > 0:
523
+ role_hierarchy[role] = roles[:i]
524
+
525
+ # Create a wrapper class with the configuration baked in
526
+ class ConfiguredSharedAuthMiddleware(SharedAuthMiddleware):
527
+ def __init__(self, app: Callable):
528
+ super().__init__(
529
+ app=app,
530
+ user_pool=user_pool,
531
+ app_slug=app_slug,
532
+ require_role=require_role,
533
+ public_routes=public_routes,
534
+ role_hierarchy=role_hierarchy,
535
+ auto_assign_default_role=auto_assign_default_role,
536
+ )
537
+
538
+ return ConfiguredSharedAuthMiddleware
539
+
540
+
541
+ def _build_role_hierarchy(manifest_auth: dict[str, Any]) -> dict[str, list[str]] | None:
542
+ """Build role hierarchy from manifest roles."""
543
+ roles = manifest_auth.get("roles", [])
544
+ if not roles or len(roles) <= 1:
545
+ return None
546
+
547
+ # Auto-generate hierarchy: each role inherits from roles below it
548
+ role_hierarchy = {}
549
+ for i, role in enumerate(roles):
550
+ if i > 0:
551
+ role_hierarchy[role] = roles[:i]
552
+ return role_hierarchy
553
+
554
+
555
+ def _is_public_route_helper(path: str, public_routes: list[str]) -> bool:
556
+ """Check if path matches any public route pattern."""
557
+ for pattern in public_routes:
558
+ # Normalize pattern for fnmatch
559
+ if not pattern.startswith("/"):
560
+ pattern = "/" + pattern
561
+
562
+ # Check exact match
563
+ if path == pattern:
564
+ return True
565
+
566
+ # Check wildcard match
567
+ if fnmatch.fnmatch(path, pattern):
568
+ return True
569
+
570
+ # Check prefix match for patterns ending with /*
571
+ if pattern.endswith("/*"):
572
+ prefix = pattern[:-2]
573
+ if path.startswith(prefix):
574
+ return True
575
+
576
+ return False
577
+
578
+
579
+ def _extract_token_helper(
580
+ request: Request, cookie_name: str, header_name: str, header_prefix: str
581
+ ) -> str | None:
582
+ """Extract JWT token from cookie or header."""
583
+ # Try cookie first
584
+ token = request.cookies.get(cookie_name)
585
+ if token:
586
+ return token
587
+
588
+ # Try Authorization header
589
+ auth_header = request.headers.get(header_name)
590
+ if auth_header and auth_header.startswith(header_prefix):
591
+ return auth_header[len(header_prefix) :]
592
+
593
+ return None
594
+
595
+
596
+ def _create_lazy_middleware_class( # noqa: C901
597
+ app_slug: str,
598
+ require_role: str | None,
599
+ public_routes: list[str],
600
+ role_hierarchy: dict[str, list[str]] | None,
601
+ session_binding: dict[str, Any],
602
+ auto_assign_default_role: bool = False,
603
+ ) -> type:
604
+ """Create the LazySharedAuthMiddleware class with configuration."""
605
+
606
+ class LazySharedAuthMiddleware(BaseHTTPMiddleware):
607
+ """
608
+ Lazy version of SharedAuthMiddleware that gets user_pool from app.state.
609
+
610
+ This enables adding middleware at app creation time while deferring
611
+ the actual user pool initialization to the lifespan startup.
612
+ """
613
+
614
+ def __init__(self, app: Callable):
615
+ super().__init__(app)
616
+ self._app_slug = app_slug
617
+ self._require_role = require_role
618
+ self._public_routes = public_routes
619
+ self._role_hierarchy = role_hierarchy
620
+ self._session_binding = session_binding
621
+ self._auto_assign_default_role = auto_assign_default_role
622
+ self._cookie_name = AUTH_COOKIE_NAME
623
+ self._header_name = AUTH_HEADER_NAME
624
+ self._header_prefix = AUTH_HEADER_PREFIX
625
+
626
+ logger.info(
627
+ f"LazySharedAuthMiddleware initialized for '{app_slug}' "
628
+ f"(require_role={require_role}, public_routes={len(self._public_routes)}, "
629
+ f"session_binding={bool(self._session_binding)})"
630
+ )
631
+
632
+ async def dispatch(
633
+ self,
634
+ request: Request,
635
+ call_next: Callable[[Request], Response],
636
+ ) -> Response:
637
+ """Process request through auth middleware."""
638
+ # Initialize request state
639
+ request.state.user = None
640
+ request.state.user_roles = []
641
+
642
+ # Get user_pool from app.state (set during lifespan)
643
+ user_pool: SharedUserPool | None = getattr(request.app.state, "user_pool", None)
644
+
645
+ if user_pool is None:
646
+ # User pool not initialized yet, skip auth
647
+ logger.warning(
648
+ f"LazySharedAuthMiddleware: user_pool not found on app.state for '{app_slug}'"
649
+ )
650
+ return await call_next(request)
651
+
652
+ is_public = _is_public_route_helper(_get_request_path(request), self._public_routes)
653
+ token = _extract_token_helper(
654
+ request, self._cookie_name, self._header_name, self._header_prefix
655
+ )
656
+
657
+ # Handle unauthenticated requests
658
+ if not token:
659
+ return await self._handle_no_token(is_public, request, call_next)
660
+
661
+ # Authenticate and authorize user
662
+ auth_result = await self._authenticate_and_authorize(
663
+ request, user_pool, token, is_public, call_next
664
+ )
665
+ if auth_result is not None:
666
+ return auth_result
667
+
668
+ return await call_next(request)
669
+
670
+ async def _authenticate_and_authorize(
671
+ self,
672
+ request: Request,
673
+ user_pool: SharedUserPool,
674
+ token: str,
675
+ is_public: bool,
676
+ call_next: Callable[[Request], Response],
677
+ ) -> Response | None:
678
+ """Authenticate user and check authorization."""
679
+ # Validate token and get user
680
+ user = await user_pool.validate_token(token)
681
+ if not user:
682
+ return await self._handle_invalid_token(is_public, request, call_next)
683
+
684
+ # Validate session binding if configured
685
+ binding_error = await self._validate_session_binding(request, token)
686
+ if binding_error:
687
+ return await self._handle_binding_error(
688
+ binding_error, is_public, request, call_next
689
+ )
690
+
691
+ # Set user on request state
692
+ request.state.user = user
693
+ request.state.user_roles = SharedUserPool.get_user_roles_for_app(user, self._app_slug)
694
+
695
+ # Check role requirement (only for non-public routes)
696
+ if not is_public and self._require_role:
697
+ role_check_result = await self._check_and_assign_role(user, user_pool, request)
698
+ if role_check_result is not None:
699
+ return role_check_result
700
+
701
+ return None
702
+
703
+ async def _handle_no_token(
704
+ self,
705
+ is_public: bool,
706
+ request: Request,
707
+ call_next: Callable[[Request], Response],
708
+ ) -> Response:
709
+ """Handle request with no token."""
710
+ if not is_public and self._require_role:
711
+ return self._unauthorized_response("Authentication required")
712
+ return await call_next(request)
713
+
714
+ async def _handle_invalid_token(
715
+ self,
716
+ is_public: bool,
717
+ request: Request,
718
+ call_next: Callable[[Request], Response],
719
+ ) -> Response:
720
+ """Handle request with invalid token."""
721
+ if is_public:
722
+ return await call_next(request)
723
+ return self._unauthorized_response("Invalid or expired token")
724
+
725
+ async def _handle_binding_error(
726
+ self,
727
+ binding_error: str,
728
+ is_public: bool,
729
+ request: Request,
730
+ call_next: Callable[[Request], Response],
731
+ ) -> Response:
732
+ """Handle session binding validation error."""
733
+ if is_public:
734
+ logger.warning(f"Session binding mismatch on public route: {binding_error}")
735
+ return await call_next(request)
736
+ return self._forbidden_response(binding_error)
737
+
738
+ @staticmethod
739
+ def _unauthorized_response(detail: str) -> JSONResponse:
740
+ """Return 401 Unauthorized response."""
741
+ return JSONResponse(
742
+ status_code=401,
743
+ content={"detail": detail, "error": "unauthorized"},
744
+ )
745
+
746
+ @staticmethod
747
+ def _forbidden_response(detail: str) -> JSONResponse:
748
+ """Return 403 Forbidden response."""
749
+ return JSONResponse(
750
+ status_code=403,
751
+ content={"detail": detail, "error": "forbidden"},
752
+ )
753
+
754
+ async def _check_and_assign_role(
755
+ self,
756
+ user: dict[str, Any],
757
+ user_pool: SharedUserPool,
758
+ request: Request,
759
+ ) -> Response | None:
760
+ """
761
+ Check if user has required role and auto-assign if needed.
762
+
763
+ Returns Response if access should be denied, None if OK.
764
+ """
765
+ user_roles = request.state.user_roles
766
+ has_required_role = SharedUserPool.user_has_role(
767
+ user,
768
+ self._app_slug,
769
+ self._require_role,
770
+ self._role_hierarchy,
771
+ )
772
+
773
+ if has_required_role:
774
+ return None
775
+
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:
779
+ await self._try_auto_assign_role(user, user_pool, request)
780
+
781
+ # Check again after potential auto-assignment
782
+ if not SharedUserPool.user_has_role(
783
+ user,
784
+ self._app_slug,
785
+ self._require_role,
786
+ self._role_hierarchy,
787
+ ):
788
+ return self._forbidden_response(
789
+ f"Role '{self._require_role}' required for this app"
790
+ )
791
+
792
+ return None
793
+
794
+ async def _try_auto_assign_role(
795
+ self,
796
+ user: dict[str, Any],
797
+ user_pool: SharedUserPool,
798
+ request: Request,
799
+ ) -> None:
800
+ """
801
+ Attempt to auto-assign required role to user.
802
+
803
+ SECURITY: Only called if auto_assign_default_role is enabled and user has
804
+ no roles. This prevents privilege escalation.
805
+ """
806
+ user_email = user.get("email")
807
+ if not user_email:
808
+ return
809
+
810
+ try:
811
+ # Auto-assign the required role
812
+ success = await user_pool.update_user_roles(
813
+ user_email, self._app_slug, [self._require_role]
814
+ )
815
+ if success:
816
+ # Refresh user data to include new role
817
+ updated_user = await user_pool.get_user_by_email(user_email)
818
+ if updated_user:
819
+ request.state.user = updated_user
820
+ request.state.user_roles = [self._require_role]
821
+ logger.info(
822
+ f"Auto-assigned role '{self._require_role}' to user "
823
+ f"{user_email} for app '{self._app_slug}' "
824
+ f"(auto_assign_default_role enabled)"
825
+ )
826
+ else:
827
+ logger.warning(
828
+ f"Failed to refresh user after auto-assigning role: " f"{user_email}"
829
+ )
830
+ else:
831
+ logger.warning(
832
+ f"Failed to auto-assign role '{self._require_role}' to "
833
+ f"user {user_email} for app '{self._app_slug}'"
834
+ )
835
+ except (PyMongoError, ValueError, AttributeError) as e:
836
+ logger.error(
837
+ f"Error auto-assigning role to user {user_email}: {e}",
838
+ exc_info=True,
839
+ )
840
+
841
+ async def _validate_session_binding(
842
+ self,
843
+ request: Request,
844
+ token: str,
845
+ ) -> str | None:
846
+ """
847
+ Validate session binding claims in token.
848
+
849
+ Returns error message if validation fails, None if OK.
850
+ """
851
+ if not self._session_binding:
852
+ return None
853
+
854
+ try:
855
+ # Decode token without verification to get claims
856
+ # (verification already done in validate_token)
857
+ payload = jwt.decode(token, options={"verify_signature": False})
858
+
859
+ # Check IP binding
860
+ ip_error = self._check_ip_binding(request, payload)
861
+ if ip_error:
862
+ return ip_error
863
+
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
868
+
869
+ return None
870
+
871
+ except jwt.InvalidTokenError as e:
872
+ logger.warning(f"Error validating session binding: {e}")
873
+ return None # Don't reject for binding check errors
874
+
875
+ def _check_ip_binding(self, request: Request, payload: dict) -> str | None:
876
+ """Check IP binding from token payload."""
877
+ if not self._session_binding.get("bind_ip", False):
878
+ return None
879
+
880
+ token_ip = payload.get("ip")
881
+ if not token_ip:
882
+ return None
883
+
884
+ client_ip = _get_client_ip(request)
885
+ if client_ip and client_ip != token_ip:
886
+ logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
887
+ return "Session bound to different IP address"
888
+
889
+ return None
890
+
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
+ """
897
+ if not self._session_binding.get("bind_fingerprint", True):
898
+ return None
899
+
900
+ token_fp = payload.get("fp")
901
+ if not token_fp:
902
+ return None
903
+
904
+ strict_fingerprint = self._session_binding.get(
905
+ "strict_fingerprint", True
906
+ ) # Default: strict
907
+ client_fp = _compute_fingerprint(request)
908
+ if client_fp != token_fp:
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
923
+
924
+ return LazySharedAuthMiddleware
925
+
926
+
927
+ def create_shared_auth_middleware_lazy(
928
+ app_slug: str,
929
+ manifest_auth: dict[str, Any],
930
+ ) -> type:
931
+ """
932
+ Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
933
+
934
+ This allows middleware to be added at app creation time (before startup),
935
+ while the actual SharedUserPool is initialized during the lifespan.
936
+ The middleware accesses `request.app.state.user_pool` at request time.
937
+
938
+ Args:
939
+ app_slug: Current app's slug
940
+ manifest_auth: Auth section from manifest
941
+
942
+ Returns:
943
+ Configured middleware class ready to add to FastAPI app
944
+
945
+ Usage:
946
+ # At app creation time:
947
+ middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
948
+ app.add_middleware(middleware_class)
949
+
950
+ # During lifespan startup:
951
+ app.state.user_pool = SharedUserPool(db)
952
+ """
953
+ require_role = manifest_auth.get("require_role")
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
+
967
+ role_hierarchy = _build_role_hierarchy(manifest_auth)
968
+ session_binding = manifest_auth.get("session_binding", {})
969
+
970
+ return _create_lazy_middleware_class(
971
+ app_slug,
972
+ require_role,
973
+ public_routes,
974
+ role_hierarchy,
975
+ session_binding,
976
+ auto_assign_default_role,
977
+ )