mdb-engine 0.2.3__py3-none-any.whl → 0.2.4__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 +7 -1
- mdb_engine/auth/README.md +6 -0
- mdb_engine/auth/shared_middleware.py +330 -119
- mdb_engine/core/manifest.py +21 -0
- mdb_engine/embeddings/service.py +37 -8
- mdb_engine/memory/README.md +93 -2
- mdb_engine/memory/service.py +348 -1096
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/METADATA +81 -6
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/RECORD +15 -14
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/WHEEL +1 -1
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.2.3.dist-info → mdb_engine-0.2.4.dist-info}/top_level.txt +0 -0
mdb_engine/__init__.py
CHANGED
|
@@ -78,7 +78,10 @@ from .indexes import (
|
|
|
78
78
|
# Repository pattern
|
|
79
79
|
from .repositories import Entity, MongoRepository, Repository, UnitOfWork
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
# Utilities
|
|
82
|
+
from .utils import clean_mongo_doc, clean_mongo_docs
|
|
83
|
+
|
|
84
|
+
__version__ = "0.2.4" # Patch version bump for exception handling improvements
|
|
82
85
|
|
|
83
86
|
__all__ = [
|
|
84
87
|
# Core Engine
|
|
@@ -127,4 +130,7 @@ __all__ = [
|
|
|
127
130
|
"AsyncAtlasIndexManager",
|
|
128
131
|
"AutoIndexManager",
|
|
129
132
|
"run_index_creation_for_collection",
|
|
133
|
+
# Utilities
|
|
134
|
+
"clean_mongo_doc",
|
|
135
|
+
"clean_mongo_docs",
|
|
130
136
|
]
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
manifest_auth
|
|
370
|
-
)
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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 =
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
+
)
|
mdb_engine/core/manifest.py
CHANGED
|
@@ -184,6 +184,27 @@ MANIFEST_SCHEMA_V2 = {
|
|
|
184
184
|
"Works in both auth modes."
|
|
185
185
|
),
|
|
186
186
|
},
|
|
187
|
+
"auth_hub_url": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"format": "uri",
|
|
190
|
+
"description": (
|
|
191
|
+
"URL of the authentication hub for SSO apps (shared mode only). "
|
|
192
|
+
"Used for redirecting unauthenticated users to login. "
|
|
193
|
+
"Example: 'http://localhost:8000' or 'https://auth.example.com'. "
|
|
194
|
+
"Can be overridden via AUTH_HUB_URL environment variable."
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
"related_apps": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"additionalProperties": {"type": "string", "format": "uri"},
|
|
200
|
+
"description": (
|
|
201
|
+
"Map of related app slugs to their URLs for cross-app navigation "
|
|
202
|
+
"(useful in shared auth mode). Keys are app slugs, values are URLs. "
|
|
203
|
+
"Example: {'dashboard': 'http://localhost:8001', "
|
|
204
|
+
"'click_tracker': 'http://localhost:8000'}. "
|
|
205
|
+
"Can be overridden via {APP_SLUG_UPPER}_URL environment variables."
|
|
206
|
+
),
|
|
207
|
+
},
|
|
187
208
|
"policy": {
|
|
188
209
|
"type": "object",
|
|
189
210
|
"properties": {
|
mdb_engine/embeddings/service.py
CHANGED
|
@@ -445,30 +445,39 @@ class EmbeddingService:
|
|
|
445
445
|
logger.error(f"Error chunking text: {e}", exc_info=True)
|
|
446
446
|
raise EmbeddingServiceError(f"Chunking failed: {str(e)}") from e
|
|
447
447
|
|
|
448
|
-
async def
|
|
448
|
+
async def embed(self, text: str | list[str], model: str | None = None) -> list[list[float]]:
|
|
449
449
|
"""
|
|
450
|
-
Generate embeddings for text
|
|
450
|
+
Generate embeddings for text or a list of texts.
|
|
451
451
|
|
|
452
|
-
|
|
452
|
+
Natural API that works with both single strings and lists.
|
|
453
453
|
|
|
454
454
|
Args:
|
|
455
|
-
|
|
455
|
+
text: A single string or list of strings to embed
|
|
456
456
|
model: Optional model identifier (passed to embedding provider)
|
|
457
457
|
|
|
458
458
|
Returns:
|
|
459
|
-
List of embedding vectors (each is a list of floats)
|
|
459
|
+
List of embedding vectors (each is a list of floats).
|
|
460
|
+
If input was a single string, returns a list containing one vector.
|
|
460
461
|
|
|
461
462
|
Example:
|
|
462
|
-
|
|
463
|
-
vectors = await service.
|
|
463
|
+
# Single string
|
|
464
|
+
vectors = await service.embed("Hello world", model="text-embedding-3-small")
|
|
465
|
+
# vectors is [[0.1, 0.2, ...]]
|
|
466
|
+
|
|
467
|
+
# List of strings (batch - more efficient)
|
|
468
|
+
vectors = await service.embed(["chunk 1", "chunk 2"], model="text-embedding-3-small")
|
|
469
|
+
# vectors is [[0.1, ...], [0.2, ...]]
|
|
464
470
|
"""
|
|
471
|
+
# Normalize to list
|
|
472
|
+
chunks = [text] if isinstance(text, str) else text
|
|
473
|
+
|
|
465
474
|
if not chunks:
|
|
466
475
|
return []
|
|
467
476
|
|
|
468
477
|
try:
|
|
469
478
|
# Use EmbeddingProvider's embed method (handles retries, logging, etc.)
|
|
470
479
|
vectors = await self.embedding_provider.embed(chunks, model=model)
|
|
471
|
-
logger.info(f"Generated {len(vectors)}
|
|
480
|
+
logger.info(f"Generated {len(vectors)} embedding(s)")
|
|
472
481
|
return vectors
|
|
473
482
|
except (
|
|
474
483
|
AttributeError,
|
|
@@ -481,6 +490,26 @@ class EmbeddingService:
|
|
|
481
490
|
logger.error(f"Error generating embeddings: {e}", exc_info=True)
|
|
482
491
|
raise EmbeddingServiceError(f"Embedding generation failed: {str(e)}") from e
|
|
483
492
|
|
|
493
|
+
async def embed_chunks(self, chunks: list[str], model: str | None = None) -> list[list[float]]:
|
|
494
|
+
"""
|
|
495
|
+
Generate embeddings for text chunks (list only).
|
|
496
|
+
|
|
497
|
+
DEPRECATED: Use embed() instead, which accepts both strings and lists.
|
|
498
|
+
This method is kept for backward compatibility.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
chunks: List of text chunks to embed
|
|
502
|
+
model: Optional model identifier (passed to embedding provider)
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
List of embedding vectors (each is a list of floats)
|
|
506
|
+
|
|
507
|
+
Example:
|
|
508
|
+
chunks = ["chunk 1", "chunk 2"]
|
|
509
|
+
vectors = await service.embed_chunks(chunks, model="text-embedding-3-small")
|
|
510
|
+
"""
|
|
511
|
+
return await self.embed(chunks, model=model)
|
|
512
|
+
|
|
484
513
|
async def process_and_store(
|
|
485
514
|
self,
|
|
486
515
|
text_content: str,
|