mdb-engine 0.2.3__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mdb_engine/__init__.py CHANGED
@@ -78,7 +78,11 @@ from .indexes import (
78
78
  # Repository pattern
79
79
  from .repositories import Entity, MongoRepository, Repository, UnitOfWork
80
80
 
81
- __version__ = "0.2.1" # Major version bump for new DI system
81
+ # Utilities
82
+ from .utils import clean_mongo_doc, clean_mongo_docs
83
+
84
+ __version__ = "0.3.0" # Minor version bump: Multi-app mounting, SSO improvements,
85
+ # and exception handling fixes
82
86
 
83
87
  __all__ = [
84
88
  # Core Engine
@@ -127,4 +131,7 @@ __all__ = [
127
131
  "AsyncAtlasIndexManager",
128
132
  "AutoIndexManager",
129
133
  "run_index_creation_for_collection",
134
+ # Utilities
135
+ "clean_mongo_doc",
136
+ "clean_mongo_docs",
130
137
  ]
mdb_engine/auth/README.md CHANGED
@@ -42,6 +42,10 @@ All apps share a central user pool. Users authenticate once and can access any a
42
42
  {
43
43
  "auth": {
44
44
  "mode": "shared",
45
+ "auth_hub_url": "http://localhost:8000",
46
+ "related_apps": {
47
+ "dashboard": "http://localhost:8001"
48
+ },
45
49
  "roles": ["viewer", "editor", "admin"],
46
50
  "default_role": "viewer",
47
51
  "require_role": "viewer",
@@ -60,6 +64,8 @@ All apps share a central user pool. Users authenticate once and can access any a
60
64
  | Field | Description |
61
65
  |-------|-------------|
62
66
  | `roles` | Available roles for this app |
67
+ | `auth_hub_url` | URL of the authentication hub for SSO apps. Used for redirecting unauthenticated users to login. Can be overridden via `AUTH_HUB_URL` environment variable |
68
+ | `related_apps` | Map of related app slugs to their URLs for cross-app navigation. Keys are app slugs, values are URLs. Can be overridden via `{APP_SLUG_UPPER}_URL` environment variables |
63
69
  | `default_role` | Role assigned to new users |
64
70
  | `require_role` | Minimum role required to access app |
65
71
  | `public_routes` | Routes that don't require authentication |
@@ -36,6 +36,7 @@ from collections.abc import Callable
36
36
  from typing import Any
37
37
 
38
38
  import jwt
39
+ from pymongo.errors import PyMongoError
39
40
  from starlette.middleware.base import BaseHTTPMiddleware
40
41
  from starlette.requests import Request
41
42
  from starlette.responses import JSONResponse, Response
@@ -204,15 +205,63 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
204
205
 
205
206
  # Check role requirement (only for non-public routes)
206
207
  if not is_public and self._require_role:
207
- if not SharedUserPool.user_has_role(
208
+ user_roles = request.state.user_roles
209
+ has_required_role = SharedUserPool.user_has_role(
208
210
  user,
209
211
  self._app_slug,
210
212
  self._require_role,
211
213
  self._role_hierarchy,
212
- ):
213
- return self._forbidden_response(
214
- f"Role '{self._require_role}' required for this app"
215
- )
214
+ )
215
+
216
+ if not has_required_role:
217
+ # Auto-assign required role if user has no roles for this app
218
+ # This is a fallback for SSO scenarios where users might be authenticated
219
+ # but not yet assigned roles. Only do this if they have NO roles (not if
220
+ # they have other roles but not the required one - prevents privilege escalation).
221
+ if not user_roles:
222
+ user_email = user.get("email")
223
+ if user_email:
224
+ try:
225
+ # Auto-assign the required role
226
+ success = await user_pool.update_user_roles(
227
+ user_email, self._app_slug, [self._require_role]
228
+ )
229
+ if success:
230
+ # Refresh user data to include new role
231
+ user = await user_pool.get_user_by_email(user_email)
232
+ if user:
233
+ request.state.user = user
234
+ request.state.user_roles = [self._require_role]
235
+ logger.info(
236
+ f"Auto-assigned role '{self._require_role}' to user "
237
+ f"{user_email} for app '{self._app_slug}'"
238
+ )
239
+ else:
240
+ logger.warning(
241
+ f"Failed to refresh user after auto-assigning role: "
242
+ f"{user_email}"
243
+ )
244
+ else:
245
+ logger.warning(
246
+ f"Failed to auto-assign role '{self._require_role}' to "
247
+ f"user {user_email} for app '{self._app_slug}'"
248
+ )
249
+ except (PyMongoError, ValueError, AttributeError) as e:
250
+ logger.error(
251
+ f"Error auto-assigning role to user {user_email}: {e}",
252
+ exc_info=True,
253
+ )
254
+
255
+ # Check again after potential auto-assignment
256
+ if not SharedUserPool.user_has_role(
257
+ user,
258
+ self._app_slug,
259
+ self._require_role,
260
+ self._role_hierarchy,
261
+ ):
262
+ return self._forbidden_response(
263
+ f"Role '{self._require_role}' required for this app"
264
+ )
216
265
 
217
266
  return await call_next(request)
218
267
 
@@ -364,47 +413,69 @@ def create_shared_auth_middleware(
364
413
  return ConfiguredSharedAuthMiddleware
365
414
 
366
415
 
367
- def create_shared_auth_middleware_lazy(
368
- app_slug: str,
369
- manifest_auth: dict[str, Any],
370
- ) -> type:
371
- """
372
- Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
416
+ def _build_role_hierarchy(manifest_auth: dict[str, Any]) -> dict[str, list[str]] | None:
417
+ """Build role hierarchy from manifest roles."""
418
+ roles = manifest_auth.get("roles", [])
419
+ if not roles or len(roles) <= 1:
420
+ return None
373
421
 
374
- This allows middleware to be added at app creation time (before startup),
375
- while the actual SharedUserPool is initialized during the lifespan.
376
- The middleware accesses `request.app.state.user_pool` at request time.
422
+ # Auto-generate hierarchy: each role inherits from roles below it
423
+ role_hierarchy = {}
424
+ for i, role in enumerate(roles):
425
+ if i > 0:
426
+ role_hierarchy[role] = roles[:i]
427
+ return role_hierarchy
428
+
429
+
430
+ def _is_public_route_helper(path: str, public_routes: list[str]) -> bool:
431
+ """Check if path matches any public route pattern."""
432
+ for pattern in public_routes:
433
+ # Normalize pattern for fnmatch
434
+ if not pattern.startswith("/"):
435
+ pattern = "/" + pattern
436
+
437
+ # Check exact match
438
+ if path == pattern:
439
+ return True
440
+
441
+ # Check wildcard match
442
+ if fnmatch.fnmatch(path, pattern):
443
+ return True
444
+
445
+ # Check prefix match for patterns ending with /*
446
+ if pattern.endswith("/*"):
447
+ prefix = pattern[:-2]
448
+ if path.startswith(prefix):
449
+ return True
377
450
 
378
- Args:
379
- app_slug: Current app's slug
380
- manifest_auth: Auth section from manifest
451
+ return False
381
452
 
382
- Returns:
383
- Configured middleware class ready to add to FastAPI app
384
453
 
385
- Usage:
386
- # At app creation time:
387
- middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
388
- app.add_middleware(middleware_class)
454
+ def _extract_token_helper(
455
+ request: Request, cookie_name: str, header_name: str, header_prefix: str
456
+ ) -> str | None:
457
+ """Extract JWT token from cookie or header."""
458
+ # Try cookie first
459
+ token = request.cookies.get(cookie_name)
460
+ if token:
461
+ return token
389
462
 
390
- # During lifespan startup:
391
- app.state.user_pool = SharedUserPool(db)
392
- """
393
- require_role = manifest_auth.get("require_role")
394
- public_routes = manifest_auth.get("public_routes", [])
463
+ # Try Authorization header
464
+ auth_header = request.headers.get(header_name)
465
+ if auth_header and auth_header.startswith(header_prefix):
466
+ return auth_header[len(header_prefix) :]
395
467
 
396
- # Build role hierarchy from manifest if available
397
- role_hierarchy = None
398
- roles = manifest_auth.get("roles", [])
399
- if roles and len(roles) > 1:
400
- # Auto-generate hierarchy: each role inherits from roles below it
401
- role_hierarchy = {}
402
- for i, role in enumerate(roles):
403
- if i > 0:
404
- role_hierarchy[role] = roles[:i]
468
+ return None
405
469
 
406
- # Session binding configuration
407
- session_binding = manifest_auth.get("session_binding", {})
470
+
471
+ def _create_lazy_middleware_class(
472
+ app_slug: str,
473
+ require_role: str | None,
474
+ public_routes: list[str],
475
+ role_hierarchy: dict[str, list[str]] | None,
476
+ session_binding: dict[str, Any],
477
+ ) -> type:
478
+ """Create the LazySharedAuthMiddleware class with configuration."""
408
479
 
409
480
  class LazySharedAuthMiddleware(BaseHTTPMiddleware):
410
481
  """
@@ -451,35 +522,44 @@ def create_shared_auth_middleware_lazy(
451
522
  )
452
523
  return await call_next(request)
453
524
 
454
- is_public = self._is_public_route(request.url.path)
455
-
456
- # Extract token from cookie or header
457
- token = self._extract_token(request)
525
+ is_public = _is_public_route_helper(request.url.path, self._public_routes)
526
+ token = _extract_token_helper(
527
+ request, self._cookie_name, self._header_name, self._header_prefix
528
+ )
458
529
 
530
+ # Handle unauthenticated requests
459
531
  if not token:
460
- # No token provided
461
- if not is_public and self._require_role:
462
- return self._unauthorized_response("Authentication required")
463
- # No role required or public route, continue without user
464
- return await call_next(request)
532
+ return await self._handle_no_token(is_public, request, call_next)
465
533
 
534
+ # Authenticate and authorize user
535
+ auth_result = await self._authenticate_and_authorize(
536
+ request, user_pool, token, is_public, call_next
537
+ )
538
+ if auth_result is not None:
539
+ return auth_result
540
+
541
+ return await call_next(request)
542
+
543
+ async def _authenticate_and_authorize(
544
+ self,
545
+ request: Request,
546
+ user_pool: SharedUserPool,
547
+ token: str,
548
+ is_public: bool,
549
+ call_next: Callable[[Request], Response],
550
+ ) -> Response | None:
551
+ """Authenticate user and check authorization."""
466
552
  # Validate token and get user
467
553
  user = await user_pool.validate_token(token)
468
-
469
554
  if not user:
470
- # Invalid token - for public routes, continue without user
471
- if is_public:
472
- return await call_next(request)
473
- return self._unauthorized_response("Invalid or expired token")
555
+ return await self._handle_invalid_token(is_public, request, call_next)
474
556
 
475
557
  # Validate session binding if configured
476
558
  binding_error = await self._validate_session_binding(request, token)
477
559
  if binding_error:
478
- if is_public:
479
- # For public routes, log but continue
480
- logger.warning(f"Session binding mismatch on public route: {binding_error}")
481
- else:
482
- return self._forbidden_response(binding_error)
560
+ return await self._handle_binding_error(
561
+ binding_error, is_public, request, call_next
562
+ )
483
563
 
484
564
  # Set user on request state
485
565
  request.state.user = user
@@ -487,54 +567,46 @@ def create_shared_auth_middleware_lazy(
487
567
 
488
568
  # Check role requirement (only for non-public routes)
489
569
  if not is_public and self._require_role:
490
- if not SharedUserPool.user_has_role(
491
- user,
492
- self._app_slug,
493
- self._require_role,
494
- self._role_hierarchy,
495
- ):
496
- return self._forbidden_response(
497
- f"Role '{self._require_role}' required for this app"
498
- )
499
-
500
- return await call_next(request)
501
-
502
- def _extract_token(self, request: Request) -> str | None:
503
- """Extract JWT token from cookie or header."""
504
- # Try cookie first
505
- token = request.cookies.get(self._cookie_name)
506
- if token:
507
- return token
508
-
509
- # Try Authorization header
510
- auth_header = request.headers.get(self._header_name)
511
- if auth_header and auth_header.startswith(self._header_prefix):
512
- return auth_header[len(self._header_prefix) :]
570
+ role_check_result = await self._check_and_assign_role(user, user_pool, request)
571
+ if role_check_result is not None:
572
+ return role_check_result
513
573
 
514
574
  return None
515
575
 
516
- def _is_public_route(self, path: str) -> bool:
517
- """Check if path matches any public route pattern."""
518
- for pattern in self._public_routes:
519
- # Normalize pattern for fnmatch
520
- if not pattern.startswith("/"):
521
- pattern = "/" + pattern
522
-
523
- # Check exact match
524
- if path == pattern:
525
- return True
526
-
527
- # Check wildcard match
528
- if fnmatch.fnmatch(path, pattern):
529
- return True
576
+ async def _handle_no_token(
577
+ self,
578
+ is_public: bool,
579
+ request: Request,
580
+ call_next: Callable[[Request], Response],
581
+ ) -> Response:
582
+ """Handle request with no token."""
583
+ if not is_public and self._require_role:
584
+ return self._unauthorized_response("Authentication required")
585
+ return await call_next(request)
530
586
 
531
- # Check prefix match for patterns ending with /*
532
- if pattern.endswith("/*"):
533
- prefix = pattern[:-2]
534
- if path.startswith(prefix):
535
- return True
587
+ async def _handle_invalid_token(
588
+ self,
589
+ is_public: bool,
590
+ request: Request,
591
+ call_next: Callable[[Request], Response],
592
+ ) -> Response:
593
+ """Handle request with invalid token."""
594
+ if is_public:
595
+ return await call_next(request)
596
+ return self._unauthorized_response("Invalid or expired token")
536
597
 
537
- return False
598
+ async def _handle_binding_error(
599
+ self,
600
+ binding_error: str,
601
+ is_public: bool,
602
+ request: Request,
603
+ call_next: Callable[[Request], Response],
604
+ ) -> Response:
605
+ """Handle session binding validation error."""
606
+ if is_public:
607
+ logger.warning(f"Session binding mismatch on public route: {binding_error}")
608
+ return await call_next(request)
609
+ return self._forbidden_response(binding_error)
538
610
 
539
611
  @staticmethod
540
612
  def _unauthorized_response(detail: str) -> JSONResponse:
@@ -552,6 +624,93 @@ def create_shared_auth_middleware_lazy(
552
624
  content={"detail": detail, "error": "forbidden"},
553
625
  )
554
626
 
627
+ async def _check_and_assign_role(
628
+ self,
629
+ user: dict[str, Any],
630
+ user_pool: SharedUserPool,
631
+ request: Request,
632
+ ) -> Response | None:
633
+ """
634
+ Check if user has required role and auto-assign if needed.
635
+
636
+ Returns Response if access should be denied, None if OK.
637
+ """
638
+ user_roles = request.state.user_roles
639
+ has_required_role = SharedUserPool.user_has_role(
640
+ user,
641
+ self._app_slug,
642
+ self._require_role,
643
+ self._role_hierarchy,
644
+ )
645
+
646
+ if has_required_role:
647
+ return None
648
+
649
+ # Auto-assign required role if user has no roles for this app
650
+ if not user_roles:
651
+ await self._try_auto_assign_role(user, user_pool, request)
652
+
653
+ # Check again after potential auto-assignment
654
+ if not SharedUserPool.user_has_role(
655
+ user,
656
+ self._app_slug,
657
+ self._require_role,
658
+ self._role_hierarchy,
659
+ ):
660
+ return self._forbidden_response(
661
+ f"Role '{self._require_role}' required for this app"
662
+ )
663
+
664
+ return None
665
+
666
+ async def _try_auto_assign_role(
667
+ self,
668
+ user: dict[str, Any],
669
+ user_pool: SharedUserPool,
670
+ request: Request,
671
+ ) -> None:
672
+ """
673
+ Attempt to auto-assign required role to user.
674
+
675
+ This is a fallback for SSO scenarios where users might be authenticated
676
+ but not yet assigned roles. Only do this if they have NO roles (not if
677
+ they have other roles but not the required one - prevents privilege
678
+ escalation).
679
+ """
680
+ user_email = user.get("email")
681
+ if not user_email:
682
+ return
683
+
684
+ try:
685
+ # Auto-assign the required role
686
+ success = await user_pool.update_user_roles(
687
+ user_email, self._app_slug, [self._require_role]
688
+ )
689
+ if success:
690
+ # Refresh user data to include new role
691
+ updated_user = await user_pool.get_user_by_email(user_email)
692
+ if updated_user:
693
+ request.state.user = updated_user
694
+ request.state.user_roles = [self._require_role]
695
+ logger.info(
696
+ f"Auto-assigned role '{self._require_role}' to user "
697
+ f"{user_email} for app '{self._app_slug}'"
698
+ )
699
+ else:
700
+ logger.warning(
701
+ f"Failed to refresh user after auto-assigning role: " f"{user_email}"
702
+ )
703
+ else:
704
+ logger.warning(
705
+ f"Failed to auto-assign role '{self._require_role}' to "
706
+ f"user {user_email} for app '{self._app_slug}'"
707
+ )
708
+ except (PyMongoError, ValueError, AttributeError) as e:
709
+ logger.error(
710
+ f"Error auto-assigning role to user {user_email}: {e}",
711
+ exc_info=True,
712
+ )
713
+
555
714
  async def _validate_session_binding(
556
715
  self,
557
716
  request: Request,
@@ -571,26 +730,12 @@ def create_shared_auth_middleware_lazy(
571
730
  payload = jwt.decode(token, options={"verify_signature": False})
572
731
 
573
732
  # Check IP binding
574
- if self._session_binding.get("bind_ip", False):
575
- token_ip = payload.get("ip")
576
- if token_ip:
577
- client_ip = _get_client_ip(request)
578
- if client_ip and client_ip != token_ip:
579
- logger.warning(
580
- f"Session IP mismatch: token={token_ip}, client={client_ip}"
581
- )
582
- return "Session bound to different IP address"
733
+ ip_error = self._check_ip_binding(request, payload)
734
+ if ip_error:
735
+ return ip_error
583
736
 
584
737
  # Check fingerprint binding (soft check - just warn)
585
- if self._session_binding.get("bind_fingerprint", True):
586
- token_fp = payload.get("fp")
587
- if token_fp:
588
- client_fp = _compute_fingerprint(request)
589
- if client_fp != token_fp:
590
- logger.warning(
591
- f"Session fingerprint mismatch for user {payload.get('email')}"
592
- )
593
- # Soft check - don't reject, just log
738
+ self._check_fingerprint_binding(request, payload)
594
739
 
595
740
  return None
596
741
 
@@ -598,4 +743,70 @@ def create_shared_auth_middleware_lazy(
598
743
  logger.warning(f"Error validating session binding: {e}")
599
744
  return None # Don't reject for binding check errors
600
745
 
746
+ def _check_ip_binding(self, request: Request, payload: dict) -> str | None:
747
+ """Check IP binding from token payload."""
748
+ if not self._session_binding.get("bind_ip", False):
749
+ return None
750
+
751
+ token_ip = payload.get("ip")
752
+ if not token_ip:
753
+ return None
754
+
755
+ client_ip = _get_client_ip(request)
756
+ if client_ip and client_ip != token_ip:
757
+ logger.warning(f"Session IP mismatch: token={token_ip}, client={client_ip}")
758
+ return "Session bound to different IP address"
759
+
760
+ return None
761
+
762
+ def _check_fingerprint_binding(self, request: Request, payload: dict) -> None:
763
+ """Check fingerprint binding from token payload (soft check - just warn)."""
764
+ if not self._session_binding.get("bind_fingerprint", True):
765
+ return
766
+
767
+ token_fp = payload.get("fp")
768
+ if not token_fp:
769
+ return
770
+
771
+ client_fp = _compute_fingerprint(request)
772
+ if client_fp != token_fp:
773
+ logger.warning(f"Session fingerprint mismatch for user {payload.get('email')}")
774
+ # Soft check - don't reject, just log
775
+
601
776
  return LazySharedAuthMiddleware
777
+
778
+
779
+ def create_shared_auth_middleware_lazy(
780
+ app_slug: str,
781
+ manifest_auth: dict[str, Any],
782
+ ) -> type:
783
+ """
784
+ Factory function to create a lazy SharedAuthMiddleware that reads user_pool from app.state.
785
+
786
+ This allows middleware to be added at app creation time (before startup),
787
+ while the actual SharedUserPool is initialized during the lifespan.
788
+ The middleware accesses `request.app.state.user_pool` at request time.
789
+
790
+ Args:
791
+ app_slug: Current app's slug
792
+ manifest_auth: Auth section from manifest
793
+
794
+ Returns:
795
+ Configured middleware class ready to add to FastAPI app
796
+
797
+ Usage:
798
+ # At app creation time:
799
+ middleware_class = create_shared_auth_middleware_lazy("my_app", manifest["auth"])
800
+ app.add_middleware(middleware_class)
801
+
802
+ # During lifespan startup:
803
+ app.state.user_pool = SharedUserPool(db)
804
+ """
805
+ require_role = manifest_auth.get("require_role")
806
+ public_routes = manifest_auth.get("public_routes", [])
807
+ role_hierarchy = _build_role_hierarchy(manifest_auth)
808
+ session_binding = manifest_auth.get("session_binding", {})
809
+
810
+ return _create_lazy_middleware_class(
811
+ app_slug, require_role, public_routes, role_hierarchy, session_binding
812
+ )