mdb-engine 0.2.1__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/audit.py +40 -40
- mdb_engine/auth/base.py +3 -3
- mdb_engine/auth/casbin_factory.py +6 -6
- mdb_engine/auth/config_defaults.py +5 -5
- mdb_engine/auth/config_helpers.py +12 -12
- mdb_engine/auth/cookie_utils.py +9 -9
- mdb_engine/auth/csrf.py +9 -8
- mdb_engine/auth/decorators.py +7 -6
- mdb_engine/auth/dependencies.py +22 -21
- mdb_engine/auth/integration.py +9 -9
- mdb_engine/auth/jwt.py +9 -9
- mdb_engine/auth/middleware.py +4 -3
- mdb_engine/auth/oso_factory.py +6 -6
- mdb_engine/auth/provider.py +4 -4
- mdb_engine/auth/rate_limiter.py +12 -11
- mdb_engine/auth/restrictions.py +16 -15
- mdb_engine/auth/session_manager.py +11 -13
- mdb_engine/auth/shared_middleware.py +344 -132
- mdb_engine/auth/shared_users.py +20 -20
- mdb_engine/auth/token_lifecycle.py +10 -12
- mdb_engine/auth/token_store.py +4 -5
- mdb_engine/auth/users.py +51 -52
- mdb_engine/auth/utils.py +29 -33
- mdb_engine/cli/commands/generate.py +6 -6
- mdb_engine/cli/utils.py +4 -4
- mdb_engine/config.py +6 -7
- mdb_engine/core/app_registration.py +12 -12
- mdb_engine/core/app_secrets.py +1 -2
- mdb_engine/core/connection.py +3 -4
- mdb_engine/core/encryption.py +1 -2
- mdb_engine/core/engine.py +43 -44
- mdb_engine/core/manifest.py +80 -58
- mdb_engine/core/ray_integration.py +10 -9
- mdb_engine/core/seeding.py +3 -3
- mdb_engine/core/service_initialization.py +10 -9
- mdb_engine/core/types.py +40 -40
- mdb_engine/database/abstraction.py +15 -16
- mdb_engine/database/connection.py +40 -12
- mdb_engine/database/query_validator.py +8 -8
- mdb_engine/database/resource_limiter.py +7 -7
- mdb_engine/database/scoped_wrapper.py +51 -58
- mdb_engine/dependencies.py +14 -13
- mdb_engine/di/container.py +12 -13
- mdb_engine/di/providers.py +14 -13
- mdb_engine/di/scopes.py +5 -5
- mdb_engine/embeddings/dependencies.py +2 -2
- mdb_engine/embeddings/service.py +67 -50
- mdb_engine/exceptions.py +20 -20
- mdb_engine/indexes/helpers.py +11 -11
- mdb_engine/indexes/manager.py +9 -9
- mdb_engine/memory/README.md +93 -2
- mdb_engine/memory/service.py +361 -1109
- mdb_engine/observability/health.py +10 -9
- mdb_engine/observability/logging.py +10 -10
- mdb_engine/observability/metrics.py +8 -7
- mdb_engine/repositories/base.py +25 -25
- mdb_engine/repositories/mongo.py +17 -17
- mdb_engine/repositories/unit_of_work.py +6 -6
- mdb_engine/routing/websockets.py +19 -18
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/METADATA +88 -13
- mdb_engine-0.2.4.dist-info/RECORD +97 -0
- {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/WHEEL +1 -1
- mdb_engine-0.2.1.dist-info/RECORD +0 -96
- {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.2.1.dist-info → mdb_engine-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -32,9 +32,11 @@ Manual usage:
|
|
|
32
32
|
import fnmatch
|
|
33
33
|
import hashlib
|
|
34
34
|
import logging
|
|
35
|
-
from
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any
|
|
36
37
|
|
|
37
38
|
import jwt
|
|
39
|
+
from pymongo.errors import PyMongoError
|
|
38
40
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
39
41
|
from starlette.requests import Request
|
|
40
42
|
from starlette.responses import JSONResponse, Response
|
|
@@ -49,7 +51,7 @@ AUTH_HEADER_NAME = "Authorization"
|
|
|
49
51
|
AUTH_HEADER_PREFIX = "Bearer "
|
|
50
52
|
|
|
51
53
|
|
|
52
|
-
def _get_client_ip(request: Request) ->
|
|
54
|
+
def _get_client_ip(request: Request) -> str | None:
|
|
53
55
|
"""Extract client IP address from request, handling proxies."""
|
|
54
56
|
# Check X-Forwarded-For header (behind load balancer/proxy)
|
|
55
57
|
forwarded_for = request.headers.get("x-forwarded-for")
|
|
@@ -99,12 +101,12 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
99
101
|
def __init__(
|
|
100
102
|
self,
|
|
101
103
|
app: Callable,
|
|
102
|
-
user_pool:
|
|
104
|
+
user_pool: SharedUserPool | None,
|
|
103
105
|
app_slug: str,
|
|
104
|
-
require_role:
|
|
105
|
-
public_routes:
|
|
106
|
-
role_hierarchy:
|
|
107
|
-
session_binding:
|
|
106
|
+
require_role: str | None = None,
|
|
107
|
+
public_routes: list[str] | None = None,
|
|
108
|
+
role_hierarchy: dict[str, list[str]] | None = None,
|
|
109
|
+
session_binding: dict[str, Any] | None = None,
|
|
108
110
|
cookie_name: str = AUTH_COOKIE_NAME,
|
|
109
111
|
header_name: str = AUTH_HEADER_NAME,
|
|
110
112
|
header_prefix: str = AUTH_HEADER_PREFIX,
|
|
@@ -145,7 +147,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
145
147
|
f"session_binding={bool(self._session_binding)})"
|
|
146
148
|
)
|
|
147
149
|
|
|
148
|
-
def get_user_pool(self, request: Request) ->
|
|
150
|
+
def get_user_pool(self, request: Request) -> SharedUserPool | None:
|
|
149
151
|
"""Get the user pool instance. Override in subclasses for lazy loading."""
|
|
150
152
|
return self._user_pool
|
|
151
153
|
|
|
@@ -203,15 +205,63 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
203
205
|
|
|
204
206
|
# Check role requirement (only for non-public routes)
|
|
205
207
|
if not is_public and self._require_role:
|
|
206
|
-
|
|
208
|
+
user_roles = request.state.user_roles
|
|
209
|
+
has_required_role = SharedUserPool.user_has_role(
|
|
207
210
|
user,
|
|
208
211
|
self._app_slug,
|
|
209
212
|
self._require_role,
|
|
210
213
|
self._role_hierarchy,
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
)
|
|
215
265
|
|
|
216
266
|
return await call_next(request)
|
|
217
267
|
|
|
@@ -219,7 +269,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
219
269
|
self,
|
|
220
270
|
request: Request,
|
|
221
271
|
token: str,
|
|
222
|
-
) ->
|
|
272
|
+
) -> str | None:
|
|
223
273
|
"""
|
|
224
274
|
Validate session binding claims in token.
|
|
225
275
|
|
|
@@ -260,7 +310,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
260
310
|
logger.warning(f"Error validating session binding: {e}")
|
|
261
311
|
return None # Don't reject for binding check errors
|
|
262
312
|
|
|
263
|
-
def _extract_token(self, request: Request) ->
|
|
313
|
+
def _extract_token(self, request: Request) -> str | None:
|
|
264
314
|
"""Extract JWT token from cookie or header."""
|
|
265
315
|
# Try cookie first
|
|
266
316
|
token = request.cookies.get(self._cookie_name)
|
|
@@ -317,7 +367,7 @@ class SharedAuthMiddleware(BaseHTTPMiddleware):
|
|
|
317
367
|
def create_shared_auth_middleware(
|
|
318
368
|
user_pool: SharedUserPool,
|
|
319
369
|
app_slug: str,
|
|
320
|
-
manifest_auth:
|
|
370
|
+
manifest_auth: dict[str, Any],
|
|
321
371
|
) -> type:
|
|
322
372
|
"""
|
|
323
373
|
Factory function to create SharedAuthMiddleware configured from manifest.
|
|
@@ -363,47 +413,69 @@ def create_shared_auth_middleware(
|
|
|
363
413
|
return ConfiguredSharedAuthMiddleware
|
|
364
414
|
|
|
365
415
|
|
|
366
|
-
def
|
|
367
|
-
|
|
368
|
-
manifest_auth
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
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
|
|
372
421
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
376
450
|
|
|
377
|
-
|
|
378
|
-
app_slug: Current app's slug
|
|
379
|
-
manifest_auth: Auth section from manifest
|
|
451
|
+
return False
|
|
380
452
|
|
|
381
|
-
Returns:
|
|
382
|
-
Configured middleware class ready to add to FastAPI app
|
|
383
453
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
388
462
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
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) :]
|
|
394
467
|
|
|
395
|
-
|
|
396
|
-
role_hierarchy = None
|
|
397
|
-
roles = manifest_auth.get("roles", [])
|
|
398
|
-
if roles and len(roles) > 1:
|
|
399
|
-
# Auto-generate hierarchy: each role inherits from roles below it
|
|
400
|
-
role_hierarchy = {}
|
|
401
|
-
for i, role in enumerate(roles):
|
|
402
|
-
if i > 0:
|
|
403
|
-
role_hierarchy[role] = roles[:i]
|
|
468
|
+
return None
|
|
404
469
|
|
|
405
|
-
|
|
406
|
-
|
|
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."""
|
|
407
479
|
|
|
408
480
|
class LazySharedAuthMiddleware(BaseHTTPMiddleware):
|
|
409
481
|
"""
|
|
@@ -441,7 +513,7 @@ def create_shared_auth_middleware_lazy(
|
|
|
441
513
|
request.state.user_roles = []
|
|
442
514
|
|
|
443
515
|
# Get user_pool from app.state (set during lifespan)
|
|
444
|
-
user_pool:
|
|
516
|
+
user_pool: SharedUserPool | None = getattr(request.app.state, "user_pool", None)
|
|
445
517
|
|
|
446
518
|
if user_pool is None:
|
|
447
519
|
# User pool not initialized yet, skip auth
|
|
@@ -450,35 +522,44 @@ def create_shared_auth_middleware_lazy(
|
|
|
450
522
|
)
|
|
451
523
|
return await call_next(request)
|
|
452
524
|
|
|
453
|
-
is_public =
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
)
|
|
457
529
|
|
|
530
|
+
# Handle unauthenticated requests
|
|
458
531
|
if not token:
|
|
459
|
-
|
|
460
|
-
if not is_public and self._require_role:
|
|
461
|
-
return self._unauthorized_response("Authentication required")
|
|
462
|
-
# No role required or public route, continue without user
|
|
463
|
-
return await call_next(request)
|
|
532
|
+
return await self._handle_no_token(is_public, request, call_next)
|
|
464
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."""
|
|
465
552
|
# Validate token and get user
|
|
466
553
|
user = await user_pool.validate_token(token)
|
|
467
|
-
|
|
468
554
|
if not user:
|
|
469
|
-
|
|
470
|
-
if is_public:
|
|
471
|
-
return await call_next(request)
|
|
472
|
-
return self._unauthorized_response("Invalid or expired token")
|
|
555
|
+
return await self._handle_invalid_token(is_public, request, call_next)
|
|
473
556
|
|
|
474
557
|
# Validate session binding if configured
|
|
475
558
|
binding_error = await self._validate_session_binding(request, token)
|
|
476
559
|
if binding_error:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
else:
|
|
481
|
-
return self._forbidden_response(binding_error)
|
|
560
|
+
return await self._handle_binding_error(
|
|
561
|
+
binding_error, is_public, request, call_next
|
|
562
|
+
)
|
|
482
563
|
|
|
483
564
|
# Set user on request state
|
|
484
565
|
request.state.user = user
|
|
@@ -486,54 +567,46 @@ def create_shared_auth_middleware_lazy(
|
|
|
486
567
|
|
|
487
568
|
# Check role requirement (only for non-public routes)
|
|
488
569
|
if not is_public and self._require_role:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
self._require_role,
|
|
493
|
-
self._role_hierarchy,
|
|
494
|
-
):
|
|
495
|
-
return self._forbidden_response(
|
|
496
|
-
f"Role '{self._require_role}' required for this app"
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
return await call_next(request)
|
|
500
|
-
|
|
501
|
-
def _extract_token(self, request: Request) -> Optional[str]:
|
|
502
|
-
"""Extract JWT token from cookie or header."""
|
|
503
|
-
# Try cookie first
|
|
504
|
-
token = request.cookies.get(self._cookie_name)
|
|
505
|
-
if token:
|
|
506
|
-
return token
|
|
507
|
-
|
|
508
|
-
# Try Authorization header
|
|
509
|
-
auth_header = request.headers.get(self._header_name)
|
|
510
|
-
if auth_header and auth_header.startswith(self._header_prefix):
|
|
511
|
-
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
|
|
512
573
|
|
|
513
574
|
return None
|
|
514
575
|
|
|
515
|
-
def
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
# Check wildcard match
|
|
527
|
-
if fnmatch.fnmatch(path, pattern):
|
|
528
|
-
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)
|
|
529
586
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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")
|
|
535
597
|
|
|
536
|
-
|
|
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)
|
|
537
610
|
|
|
538
611
|
@staticmethod
|
|
539
612
|
def _unauthorized_response(detail: str) -> JSONResponse:
|
|
@@ -551,11 +624,98 @@ def create_shared_auth_middleware_lazy(
|
|
|
551
624
|
content={"detail": detail, "error": "forbidden"},
|
|
552
625
|
)
|
|
553
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
|
+
|
|
554
714
|
async def _validate_session_binding(
|
|
555
715
|
self,
|
|
556
716
|
request: Request,
|
|
557
717
|
token: str,
|
|
558
|
-
) ->
|
|
718
|
+
) -> str | None:
|
|
559
719
|
"""
|
|
560
720
|
Validate session binding claims in token.
|
|
561
721
|
|
|
@@ -570,26 +730,12 @@ def create_shared_auth_middleware_lazy(
|
|
|
570
730
|
payload = jwt.decode(token, options={"verify_signature": False})
|
|
571
731
|
|
|
572
732
|
# Check IP binding
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
client_ip = _get_client_ip(request)
|
|
577
|
-
if client_ip and client_ip != token_ip:
|
|
578
|
-
logger.warning(
|
|
579
|
-
f"Session IP mismatch: token={token_ip}, client={client_ip}"
|
|
580
|
-
)
|
|
581
|
-
return "Session bound to different IP address"
|
|
733
|
+
ip_error = self._check_ip_binding(request, payload)
|
|
734
|
+
if ip_error:
|
|
735
|
+
return ip_error
|
|
582
736
|
|
|
583
737
|
# Check fingerprint binding (soft check - just warn)
|
|
584
|
-
|
|
585
|
-
token_fp = payload.get("fp")
|
|
586
|
-
if token_fp:
|
|
587
|
-
client_fp = _compute_fingerprint(request)
|
|
588
|
-
if client_fp != token_fp:
|
|
589
|
-
logger.warning(
|
|
590
|
-
f"Session fingerprint mismatch for user {payload.get('email')}"
|
|
591
|
-
)
|
|
592
|
-
# Soft check - don't reject, just log
|
|
738
|
+
self._check_fingerprint_binding(request, payload)
|
|
593
739
|
|
|
594
740
|
return None
|
|
595
741
|
|
|
@@ -597,4 +743,70 @@ def create_shared_auth_middleware_lazy(
|
|
|
597
743
|
logger.warning(f"Error validating session binding: {e}")
|
|
598
744
|
return None # Don't reject for binding check errors
|
|
599
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
|
+
|
|
600
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
|
+
)
|