mdb-engine 0.1.6__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/README.md +144 -0
- mdb_engine/__init__.py +37 -0
- mdb_engine/auth/README.md +631 -0
- mdb_engine/auth/__init__.py +128 -0
- mdb_engine/auth/casbin_factory.py +199 -0
- mdb_engine/auth/casbin_models.py +46 -0
- mdb_engine/auth/config_defaults.py +71 -0
- mdb_engine/auth/config_helpers.py +213 -0
- mdb_engine/auth/cookie_utils.py +158 -0
- mdb_engine/auth/decorators.py +350 -0
- mdb_engine/auth/dependencies.py +747 -0
- mdb_engine/auth/helpers.py +64 -0
- mdb_engine/auth/integration.py +578 -0
- mdb_engine/auth/jwt.py +225 -0
- mdb_engine/auth/middleware.py +241 -0
- mdb_engine/auth/oso_factory.py +323 -0
- mdb_engine/auth/provider.py +570 -0
- mdb_engine/auth/restrictions.py +271 -0
- mdb_engine/auth/session_manager.py +477 -0
- mdb_engine/auth/token_lifecycle.py +213 -0
- mdb_engine/auth/token_store.py +289 -0
- mdb_engine/auth/users.py +1516 -0
- mdb_engine/auth/utils.py +614 -0
- mdb_engine/cli/__init__.py +13 -0
- mdb_engine/cli/commands/__init__.py +7 -0
- mdb_engine/cli/commands/generate.py +105 -0
- mdb_engine/cli/commands/migrate.py +83 -0
- mdb_engine/cli/commands/show.py +70 -0
- mdb_engine/cli/commands/validate.py +63 -0
- mdb_engine/cli/main.py +41 -0
- mdb_engine/cli/utils.py +92 -0
- mdb_engine/config.py +217 -0
- mdb_engine/constants.py +160 -0
- mdb_engine/core/README.md +542 -0
- mdb_engine/core/__init__.py +42 -0
- mdb_engine/core/app_registration.py +392 -0
- mdb_engine/core/connection.py +243 -0
- mdb_engine/core/engine.py +749 -0
- mdb_engine/core/index_management.py +162 -0
- mdb_engine/core/manifest.py +2793 -0
- mdb_engine/core/seeding.py +179 -0
- mdb_engine/core/service_initialization.py +355 -0
- mdb_engine/core/types.py +413 -0
- mdb_engine/database/README.md +522 -0
- mdb_engine/database/__init__.py +31 -0
- mdb_engine/database/abstraction.py +635 -0
- mdb_engine/database/connection.py +387 -0
- mdb_engine/database/scoped_wrapper.py +1721 -0
- mdb_engine/embeddings/README.md +184 -0
- mdb_engine/embeddings/__init__.py +62 -0
- mdb_engine/embeddings/dependencies.py +193 -0
- mdb_engine/embeddings/service.py +759 -0
- mdb_engine/exceptions.py +167 -0
- mdb_engine/indexes/README.md +651 -0
- mdb_engine/indexes/__init__.py +21 -0
- mdb_engine/indexes/helpers.py +145 -0
- mdb_engine/indexes/manager.py +895 -0
- mdb_engine/memory/README.md +451 -0
- mdb_engine/memory/__init__.py +30 -0
- mdb_engine/memory/service.py +1285 -0
- mdb_engine/observability/README.md +515 -0
- mdb_engine/observability/__init__.py +42 -0
- mdb_engine/observability/health.py +296 -0
- mdb_engine/observability/logging.py +161 -0
- mdb_engine/observability/metrics.py +297 -0
- mdb_engine/routing/README.md +462 -0
- mdb_engine/routing/__init__.py +73 -0
- mdb_engine/routing/websockets.py +813 -0
- mdb_engine/utils/__init__.py +7 -0
- mdb_engine-0.1.6.dist-info/METADATA +213 -0
- mdb_engine-0.1.6.dist-info/RECORD +75 -0
- mdb_engine-0.1.6.dist-info/WHEEL +5 -0
- mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
- mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
- mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI Authentication and Authorization Dependencies
|
|
3
|
+
|
|
4
|
+
Provides FastAPI dependency functions for authentication and authorization.
|
|
5
|
+
|
|
6
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import jwt
|
|
16
|
+
from fastapi import Cookie, Depends, HTTPException, Request, status
|
|
17
|
+
|
|
18
|
+
from ..exceptions import ConfigurationError
|
|
19
|
+
from .jwt import decode_jwt_token, extract_token_metadata
|
|
20
|
+
# Import from local modules
|
|
21
|
+
from .provider import AuthorizationProvider
|
|
22
|
+
from .session_manager import SessionManager
|
|
23
|
+
from .token_store import TokenBlacklist
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_SECRET_KEY_CACHE: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_secret_key() -> str:
|
|
31
|
+
"""
|
|
32
|
+
Get and validate SECRET_KEY from environment (lazy evaluation).
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ConfigurationError: If SECRET_KEY is not set or too weak
|
|
36
|
+
"""
|
|
37
|
+
global _SECRET_KEY_CACHE
|
|
38
|
+
|
|
39
|
+
if _SECRET_KEY_CACHE is not None:
|
|
40
|
+
return _SECRET_KEY_CACHE
|
|
41
|
+
|
|
42
|
+
secret_key = os.environ.get("FLASK_SECRET_KEY") or os.environ.get("SECRET_KEY")
|
|
43
|
+
|
|
44
|
+
if not secret_key:
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
"FLASK_SECRET_KEY environment variable is required for JWT token security. "
|
|
47
|
+
"Set a strong secret key (minimum 32 characters, cryptographically random). "
|
|
48
|
+
"Example: export FLASK_SECRET_KEY=$(python -c "
|
|
49
|
+
"'import secrets; print(secrets.token_urlsafe(32))')",
|
|
50
|
+
config_key="FLASK_SECRET_KEY",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if len(secret_key) < 32:
|
|
54
|
+
logger.warning(
|
|
55
|
+
f"SECRET_KEY is only {len(secret_key)} characters. "
|
|
56
|
+
"Recommendation: Use at least 32 characters for production."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_SECRET_KEY_CACHE = secret_key
|
|
60
|
+
return _SECRET_KEY_CACHE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SecretKey:
|
|
64
|
+
"""Lazy-validated secret key that behaves like a string."""
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
return _get_secret_key()
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return "<SECRET_KEY (validated on access)>"
|
|
71
|
+
|
|
72
|
+
def __eq__(self, other: Any) -> bool:
|
|
73
|
+
return str(self) == str(other) if other else False
|
|
74
|
+
|
|
75
|
+
def __ne__(self, other: Any) -> bool:
|
|
76
|
+
return not self.__eq__(other)
|
|
77
|
+
|
|
78
|
+
def __hash__(self) -> int:
|
|
79
|
+
return hash(str(self))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_secret_key_value() -> str:
|
|
83
|
+
"""Get SECRET_KEY as a string value (for use in function calls)."""
|
|
84
|
+
return _get_secret_key()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
SECRET_KEY = _SecretKey()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_next_url(next_url: Optional[str]) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Sanitizes a 'next' URL parameter to prevent Open Redirect vulnerabilities.
|
|
93
|
+
"""
|
|
94
|
+
if not next_url:
|
|
95
|
+
return "/"
|
|
96
|
+
|
|
97
|
+
if next_url.startswith("/") and "//" not in next_url and ":" not in next_url:
|
|
98
|
+
return next_url
|
|
99
|
+
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Blocked potentially unsafe redirect attempt. "
|
|
102
|
+
f"Original 'next' URL: '{next_url}'. Sanitized to '/'."
|
|
103
|
+
)
|
|
104
|
+
return "/"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def get_authz_provider(request: Request) -> AuthorizationProvider:
|
|
108
|
+
"""
|
|
109
|
+
FastAPI Dependency: Retrieves the shared, pluggable AuthZ provider
|
|
110
|
+
from app.state.
|
|
111
|
+
"""
|
|
112
|
+
# This key 'authz_provider' will be set in main.py's lifespan
|
|
113
|
+
provider = getattr(request.app.state, "authz_provider", None)
|
|
114
|
+
if not provider:
|
|
115
|
+
logger.critical("❌ get_authz_provider: AuthZ provider not found on app.state!")
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
118
|
+
detail="Server configuration error: Authorization engine not loaded.",
|
|
119
|
+
)
|
|
120
|
+
return provider
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def get_token_blacklist(request: Request) -> Optional[TokenBlacklist]:
|
|
124
|
+
"""
|
|
125
|
+
FastAPI Dependency: Retrieves token blacklist from app.state.
|
|
126
|
+
|
|
127
|
+
Returns None if blacklist is not configured (backward compatibility).
|
|
128
|
+
"""
|
|
129
|
+
blacklist = getattr(request.app.state, "token_blacklist", None)
|
|
130
|
+
return blacklist
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_session_manager(request: Request) -> Optional[SessionManager]:
|
|
134
|
+
"""
|
|
135
|
+
FastAPI Dependency: Retrieves session manager from app.state.
|
|
136
|
+
|
|
137
|
+
Returns None if session manager is not configured (backward compatibility).
|
|
138
|
+
"""
|
|
139
|
+
session_mgr = getattr(request.app.state, "session_manager", None)
|
|
140
|
+
return session_mgr
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def get_current_user(
|
|
144
|
+
request: Request,
|
|
145
|
+
token: Optional[str] = Cookie(default=None),
|
|
146
|
+
) -> Optional[Dict[str, Any]]:
|
|
147
|
+
"""
|
|
148
|
+
FastAPI Dependency: Decodes and validates the JWT stored in the 'token' cookie.
|
|
149
|
+
|
|
150
|
+
Enhanced with token blacklist checking if blacklist is available.
|
|
151
|
+
"""
|
|
152
|
+
if not token:
|
|
153
|
+
logger.debug("get_current_user: No 'token' cookie found.")
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Extract token metadata first to get jti
|
|
158
|
+
metadata = extract_token_metadata(token, str(SECRET_KEY))
|
|
159
|
+
jti = metadata.get("jti") if metadata else None
|
|
160
|
+
|
|
161
|
+
# Check blacklist if available
|
|
162
|
+
if jti:
|
|
163
|
+
blacklist = await get_token_blacklist(request)
|
|
164
|
+
if blacklist:
|
|
165
|
+
is_revoked = await blacklist.is_revoked(jti)
|
|
166
|
+
if is_revoked:
|
|
167
|
+
logger.info(
|
|
168
|
+
f"get_current_user: Token {jti} is blacklisted (revoked)"
|
|
169
|
+
)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Also check user-level revocation
|
|
173
|
+
user_id = metadata.get("user_id") or metadata.get("email")
|
|
174
|
+
if user_id:
|
|
175
|
+
user_revoked = await blacklist.is_user_revoked(user_id)
|
|
176
|
+
if user_revoked:
|
|
177
|
+
logger.info(
|
|
178
|
+
f"get_current_user: All tokens for user {user_id} are revoked"
|
|
179
|
+
)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
payload = decode_jwt_token(token, str(SECRET_KEY))
|
|
183
|
+
|
|
184
|
+
# Verify token type (should be access token for backward compatibility, or no type)
|
|
185
|
+
token_type = payload.get("type")
|
|
186
|
+
if token_type and token_type not in ("access", None):
|
|
187
|
+
logger.warning(
|
|
188
|
+
f"get_current_user: Invalid token type '{token_type}' for access token"
|
|
189
|
+
)
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
logger.debug(
|
|
193
|
+
f"get_current_user: Token successfully decoded for user "
|
|
194
|
+
f"'{payload.get('email', 'N/A')}'."
|
|
195
|
+
)
|
|
196
|
+
return payload
|
|
197
|
+
except jwt.ExpiredSignatureError:
|
|
198
|
+
logger.info("get_current_user: Authentication token has expired.")
|
|
199
|
+
return None
|
|
200
|
+
except jwt.InvalidTokenError as e:
|
|
201
|
+
logger.warning(f"get_current_user: Invalid JWT token presented: {e}")
|
|
202
|
+
return None
|
|
203
|
+
except (ValueError, TypeError):
|
|
204
|
+
logger.exception("Validation error decoding JWT token")
|
|
205
|
+
return None
|
|
206
|
+
except Exception:
|
|
207
|
+
logger.exception("Unexpected error decoding JWT token")
|
|
208
|
+
# Re-raise unexpected errors for debugging
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def get_current_user_from_request(request: Request) -> Optional[Dict[str, Any]]:
|
|
213
|
+
"""
|
|
214
|
+
Helper function to get current user from a Request object.
|
|
215
|
+
This is useful when you need to call get_current_user outside of FastAPI dependency injection.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
request: FastAPI Request object
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Optional[Dict[str, Any]]: User dict if authenticated, None otherwise
|
|
222
|
+
"""
|
|
223
|
+
token = request.cookies.get("token")
|
|
224
|
+
if not token:
|
|
225
|
+
logger.debug("get_current_user_from_request: No 'token' cookie found.")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
# Extract token metadata first to get jti
|
|
230
|
+
metadata = extract_token_metadata(token, str(SECRET_KEY))
|
|
231
|
+
jti = metadata.get("jti") if metadata else None
|
|
232
|
+
|
|
233
|
+
# Check blacklist if available
|
|
234
|
+
if jti:
|
|
235
|
+
blacklist = await get_token_blacklist(request)
|
|
236
|
+
if blacklist:
|
|
237
|
+
is_revoked = await blacklist.is_revoked(jti)
|
|
238
|
+
if is_revoked:
|
|
239
|
+
logger.info(
|
|
240
|
+
f"get_current_user_from_request: Token {jti} is blacklisted (revoked)"
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
# Also check user-level revocation
|
|
245
|
+
user_id = metadata.get("user_id") or metadata.get("email")
|
|
246
|
+
if user_id:
|
|
247
|
+
user_revoked = await blacklist.is_user_revoked(user_id)
|
|
248
|
+
if user_revoked:
|
|
249
|
+
logger.info(
|
|
250
|
+
f"get_current_user_from_request: All tokens for user "
|
|
251
|
+
f"{user_id} are revoked"
|
|
252
|
+
)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
payload = decode_jwt_token(token, str(SECRET_KEY))
|
|
256
|
+
|
|
257
|
+
# Verify token type (should be access token for backward compatibility, or no type)
|
|
258
|
+
token_type = payload.get("type")
|
|
259
|
+
if token_type and token_type not in ("access", None):
|
|
260
|
+
logger.warning(
|
|
261
|
+
f"get_current_user_from_request: Invalid token type '{token_type}' for access token"
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
logger.debug(
|
|
266
|
+
f"get_current_user_from_request: Token successfully decoded for user "
|
|
267
|
+
f"'{payload.get('email', 'N/A')}'."
|
|
268
|
+
)
|
|
269
|
+
return payload
|
|
270
|
+
except jwt.ExpiredSignatureError:
|
|
271
|
+
logger.info("get_current_user_from_request: Authentication token has expired.")
|
|
272
|
+
return None
|
|
273
|
+
except jwt.InvalidTokenError as e:
|
|
274
|
+
logger.debug(f"get_current_user_from_request: Invalid JWT token presented: {e}")
|
|
275
|
+
return None
|
|
276
|
+
except (ValueError, TypeError):
|
|
277
|
+
logger.exception("Validation error decoding JWT token from request")
|
|
278
|
+
return None
|
|
279
|
+
except Exception:
|
|
280
|
+
logger.exception("Unexpected error decoding JWT token from request")
|
|
281
|
+
# Re-raise unexpected errors for debugging
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def get_refresh_token(
|
|
286
|
+
request: Request,
|
|
287
|
+
refresh_token: Optional[str] = Cookie(default=None),
|
|
288
|
+
) -> Optional[Dict[str, Any]]:
|
|
289
|
+
"""
|
|
290
|
+
FastAPI Dependency: Validates refresh token from cookie.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
request: FastAPI Request object
|
|
294
|
+
refresh_token: Refresh token from cookie (default cookie name: 'refresh_token')
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Decoded refresh token payload or None if invalid
|
|
298
|
+
"""
|
|
299
|
+
if not refresh_token:
|
|
300
|
+
# Try alternative cookie name
|
|
301
|
+
refresh_token = request.cookies.get("refresh_token")
|
|
302
|
+
if not refresh_token:
|
|
303
|
+
logger.debug("get_refresh_token: No refresh token cookie found.")
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
# Extract token metadata first
|
|
308
|
+
metadata = extract_token_metadata(refresh_token, SECRET_KEY)
|
|
309
|
+
jti = metadata.get("jti") if metadata else None
|
|
310
|
+
|
|
311
|
+
# Check blacklist if available
|
|
312
|
+
if jti:
|
|
313
|
+
blacklist = await get_token_blacklist(request)
|
|
314
|
+
if blacklist:
|
|
315
|
+
is_revoked = await blacklist.is_revoked(jti)
|
|
316
|
+
if is_revoked:
|
|
317
|
+
logger.info(
|
|
318
|
+
f"get_refresh_token: Refresh token {jti} is blacklisted"
|
|
319
|
+
)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
payload = decode_jwt_token(refresh_token, str(SECRET_KEY))
|
|
323
|
+
|
|
324
|
+
# Verify token type
|
|
325
|
+
token_type = payload.get("type")
|
|
326
|
+
if token_type != "refresh":
|
|
327
|
+
logger.warning(
|
|
328
|
+
f"get_refresh_token: Invalid token type '{token_type}' for refresh token"
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
# Check session if available
|
|
333
|
+
session_mgr = await get_session_manager(request)
|
|
334
|
+
if session_mgr and jti:
|
|
335
|
+
session = await session_mgr.get_session_by_refresh_token(jti)
|
|
336
|
+
if not session or not session.get("active"):
|
|
337
|
+
logger.info(
|
|
338
|
+
f"get_refresh_token: Session not found or inactive for refresh token {jti}"
|
|
339
|
+
)
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Validate session fingerprint if enabled
|
|
343
|
+
from .config_helpers import get_session_fingerprinting_config
|
|
344
|
+
|
|
345
|
+
fingerprinting_config = get_session_fingerprinting_config(request)
|
|
346
|
+
if fingerprinting_config.get("enabled", True) and fingerprinting_config.get(
|
|
347
|
+
"validate_on_refresh", True
|
|
348
|
+
):
|
|
349
|
+
stored_fingerprint = session.get("session_fingerprint")
|
|
350
|
+
if stored_fingerprint:
|
|
351
|
+
from .utils import generate_session_fingerprint
|
|
352
|
+
|
|
353
|
+
device_id = request.cookies.get("device_id") or payload.get(
|
|
354
|
+
"device_id"
|
|
355
|
+
)
|
|
356
|
+
if device_id:
|
|
357
|
+
current_fingerprint = generate_session_fingerprint(
|
|
358
|
+
request, device_id
|
|
359
|
+
)
|
|
360
|
+
if current_fingerprint != stored_fingerprint:
|
|
361
|
+
logger.warning(
|
|
362
|
+
f"get_refresh_token: Session fingerprint mismatch "
|
|
363
|
+
f"for refresh token {jti}"
|
|
364
|
+
)
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
logger.debug(
|
|
368
|
+
f"get_refresh_token: Refresh token validated for user '{payload.get('email', 'N/A')}'"
|
|
369
|
+
)
|
|
370
|
+
return payload
|
|
371
|
+
except jwt.ExpiredSignatureError:
|
|
372
|
+
logger.info("get_refresh_token: Refresh token has expired.")
|
|
373
|
+
return None
|
|
374
|
+
except jwt.InvalidTokenError as e:
|
|
375
|
+
logger.warning(f"get_refresh_token: Invalid refresh token: {e}")
|
|
376
|
+
return None
|
|
377
|
+
except (ValueError, TypeError):
|
|
378
|
+
logger.exception("Validation error decoding refresh token")
|
|
379
|
+
return None
|
|
380
|
+
except Exception:
|
|
381
|
+
logger.exception("Unexpected error decoding refresh token")
|
|
382
|
+
# Re-raise unexpected errors for debugging
|
|
383
|
+
raise
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def require_admin(
|
|
387
|
+
user: Optional[Mapping[str, Any]] = Depends(get_current_user),
|
|
388
|
+
authz: AuthorizationProvider = Depends(get_authz_provider),
|
|
389
|
+
) -> Dict[str, Any]:
|
|
390
|
+
"""
|
|
391
|
+
FastAPI Dependency: Enforces admin privileges via the pluggable AuthZ provider.
|
|
392
|
+
"""
|
|
393
|
+
user_identifier = "anonymous"
|
|
394
|
+
has_perm = False
|
|
395
|
+
|
|
396
|
+
if user and user.get("email"):
|
|
397
|
+
user_identifier = user.get("email")
|
|
398
|
+
# Use the generic, async interface method
|
|
399
|
+
has_perm = await authz.check(
|
|
400
|
+
subject=user_identifier,
|
|
401
|
+
resource="admin_panel",
|
|
402
|
+
action="access",
|
|
403
|
+
user_object=dict(user), # Pass full context
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if not has_perm:
|
|
407
|
+
logger.warning(
|
|
408
|
+
f"require_admin: Admin access DENIED for {user_identifier}. Failed provider check."
|
|
409
|
+
)
|
|
410
|
+
raise HTTPException(
|
|
411
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
412
|
+
detail="Administrator privileges are required to access this resource.",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
logger.debug(
|
|
416
|
+
f"require_admin: Admin access GRANTED for user '{user.get('email')}' "
|
|
417
|
+
f"via {authz.__class__.__name__}."
|
|
418
|
+
)
|
|
419
|
+
return dict(user)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def require_admin_or_developer(
|
|
423
|
+
user: Optional[Mapping[str, Any]] = Depends(get_current_user),
|
|
424
|
+
authz: AuthorizationProvider = Depends(get_authz_provider),
|
|
425
|
+
) -> Dict[str, Any]:
|
|
426
|
+
"""
|
|
427
|
+
FastAPI Dependency: Enforces admin OR developer privileges.
|
|
428
|
+
Developers can upload apps, admins can upload any app.
|
|
429
|
+
"""
|
|
430
|
+
if not user:
|
|
431
|
+
raise HTTPException(
|
|
432
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
433
|
+
detail="Authentication required to upload apps.",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
user_email = user.get("email")
|
|
437
|
+
if not user_email:
|
|
438
|
+
raise HTTPException(
|
|
439
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
440
|
+
detail="Invalid authentication token.",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Check if user is an admin
|
|
444
|
+
is_admin = await authz.check(
|
|
445
|
+
subject=user_email,
|
|
446
|
+
resource="admin_panel",
|
|
447
|
+
action="access",
|
|
448
|
+
user_object=dict(user),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if is_admin:
|
|
452
|
+
logger.debug(
|
|
453
|
+
f"require_admin_or_developer: Admin '{user_email}' granted access to upload apps"
|
|
454
|
+
)
|
|
455
|
+
return dict(user)
|
|
456
|
+
|
|
457
|
+
# Check if user is a developer (has apps:manage_own permission)
|
|
458
|
+
is_developer = await authz.check(
|
|
459
|
+
subject=user_email,
|
|
460
|
+
resource="experiments",
|
|
461
|
+
action="manage_own",
|
|
462
|
+
user_object=dict(user),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if is_developer:
|
|
466
|
+
logger.debug(
|
|
467
|
+
f"require_admin_or_developer: Developer '{user_email}' granted "
|
|
468
|
+
f"access to upload experiments"
|
|
469
|
+
)
|
|
470
|
+
return dict(user)
|
|
471
|
+
|
|
472
|
+
# Neither admin nor developer
|
|
473
|
+
logger.warning(
|
|
474
|
+
f"require_admin_or_developer: Access DENIED for '{user_email}'. "
|
|
475
|
+
f"User is not an admin or developer."
|
|
476
|
+
)
|
|
477
|
+
raise HTTPException(
|
|
478
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
479
|
+
detail="Administrator or developer privileges are required to upload experiments.",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
async def get_current_user_or_redirect(
|
|
484
|
+
request: Request, user: Optional[Mapping[str, Any]] = Depends(get_current_user)
|
|
485
|
+
) -> Dict[str, Any]:
|
|
486
|
+
"""
|
|
487
|
+
FastAPI Dependency: Enforces user authentication. Redirects to login if not authenticated.
|
|
488
|
+
"""
|
|
489
|
+
if not user:
|
|
490
|
+
try:
|
|
491
|
+
login_route_name = "login_get"
|
|
492
|
+
login_url = request.url_for(login_route_name)
|
|
493
|
+
original_path = request.url.path
|
|
494
|
+
safe_next_path = _validate_next_url(original_path)
|
|
495
|
+
redirect_url = f"{login_url}?next={safe_next_path}"
|
|
496
|
+
|
|
497
|
+
logger.info(
|
|
498
|
+
f"get_current_user_or_redirect: User not authenticated. "
|
|
499
|
+
f"Redirecting to login. Original path: '{original_path}', "
|
|
500
|
+
f"Redirect URL: '{redirect_url}'"
|
|
501
|
+
)
|
|
502
|
+
raise HTTPException(
|
|
503
|
+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
|
504
|
+
headers={"Location": redirect_url},
|
|
505
|
+
detail="Not authenticated. Redirecting to login.",
|
|
506
|
+
)
|
|
507
|
+
except (ValueError, KeyError, AttributeError):
|
|
508
|
+
logger.exception(
|
|
509
|
+
f"Failed to generate login redirect URL for route '{login_route_name}'"
|
|
510
|
+
)
|
|
511
|
+
raise HTTPException(
|
|
512
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
513
|
+
detail="Authentication required, but redirect failed.",
|
|
514
|
+
)
|
|
515
|
+
return dict(user)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def require_permission(obj: str, act: str, force_login: bool = True):
|
|
519
|
+
"""
|
|
520
|
+
Dependency Factory: Creates a dependency checking for a specific permission
|
|
521
|
+
using the pluggable AuthZ provider.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
obj: The resource (object) to check.
|
|
525
|
+
act: The action (permission) to check.
|
|
526
|
+
force_login: If True (default), uses `get_current_user_or_redirect`.
|
|
527
|
+
If False, uses `get_current_user` and checks permissions
|
|
528
|
+
for 'anonymous' if no user is found.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
# 1. Choose the correct user dependency based on the flag
|
|
532
|
+
user_dependency = get_current_user_or_redirect if force_login else get_current_user
|
|
533
|
+
|
|
534
|
+
async def _check_permission(
|
|
535
|
+
# 2. The type hint MUST be Optional now
|
|
536
|
+
user: Optional[Dict[str, Any]] = Depends(user_dependency),
|
|
537
|
+
# 3. Ask for the generic INTERFACE
|
|
538
|
+
authz: AuthorizationProvider = Depends(get_authz_provider),
|
|
539
|
+
) -> Optional[Dict[str, Any]]: # 4. Return type is also Optional
|
|
540
|
+
"""Internal dependency function performing the AuthZ check."""
|
|
541
|
+
|
|
542
|
+
# 5. Check for 'anonymous' if user is None
|
|
543
|
+
user_email = user.get("email") if user else "anonymous"
|
|
544
|
+
|
|
545
|
+
if not user_email:
|
|
546
|
+
# This should be unreachable if 'anonymous' is the fallback
|
|
547
|
+
raise HTTPException(
|
|
548
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated."
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# 6. Use the generic, async interface method
|
|
552
|
+
has_perm = await authz.check(
|
|
553
|
+
subject=user_email,
|
|
554
|
+
resource=obj,
|
|
555
|
+
action=act,
|
|
556
|
+
user_object=user, # Pass full context (or None)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if not has_perm:
|
|
560
|
+
logger.warning(
|
|
561
|
+
f"require_permission: Access DENIED for user '{user_email}' to ('{obj}', '{act}')."
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# 7. Handle the failure
|
|
565
|
+
if not user:
|
|
566
|
+
# User is anonymous and lacks permission.
|
|
567
|
+
# 401 suggests logging in might help.
|
|
568
|
+
raise HTTPException(
|
|
569
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
570
|
+
detail=f"You must be logged in with permission to '{act}' on '{obj}'.",
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
# User is logged in but lacks permission
|
|
574
|
+
raise HTTPException(
|
|
575
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
576
|
+
detail=(
|
|
577
|
+
f"You do not have permission to perform '{act}' "
|
|
578
|
+
f"on the resource '{obj}'."
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
logger.debug(
|
|
583
|
+
f"require_permission: Access GRANTED for user '{user_email}' to ('{obj}', '{act}')."
|
|
584
|
+
)
|
|
585
|
+
return user # Returns the user dict or None
|
|
586
|
+
|
|
587
|
+
return _check_permission
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# require_experiment_access and require_experiment_ownership_or_admin
|
|
591
|
+
# depend on get_experiment_config from the application layer.
|
|
592
|
+
# These can be imported from the application layer when needed.
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
async def refresh_access_token(
|
|
596
|
+
request: Request,
|
|
597
|
+
refresh_token_payload: Dict[str, Any],
|
|
598
|
+
device_info: Optional[Dict[str, Any]] = None,
|
|
599
|
+
) -> Optional[Tuple[str, str, Dict[str, Any]]]:
|
|
600
|
+
"""
|
|
601
|
+
Refresh an access token using a valid refresh token.
|
|
602
|
+
|
|
603
|
+
This function:
|
|
604
|
+
1. Validates the refresh token
|
|
605
|
+
2. Checks session status
|
|
606
|
+
3. Generates new token pair (with rotation if enabled)
|
|
607
|
+
4. Updates session activity
|
|
608
|
+
5. Revokes old refresh token if rotation is enabled
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
request: FastAPI Request object
|
|
612
|
+
refresh_token_payload: Decoded refresh token payload
|
|
613
|
+
device_info: Optional device information for new tokens
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
Tuple of (access_token, refresh_token, metadata) or None if refresh failed
|
|
617
|
+
"""
|
|
618
|
+
try:
|
|
619
|
+
from ..config import TOKEN_ROTATION_ENABLED
|
|
620
|
+
from .jwt import generate_token_pair
|
|
621
|
+
|
|
622
|
+
user_id = refresh_token_payload.get("user_id") or refresh_token_payload.get(
|
|
623
|
+
"email"
|
|
624
|
+
)
|
|
625
|
+
old_refresh_jti = refresh_token_payload.get("jti")
|
|
626
|
+
device_id = refresh_token_payload.get("device_id")
|
|
627
|
+
|
|
628
|
+
if not user_id:
|
|
629
|
+
logger.warning("refresh_access_token: No user_id in refresh token")
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
# Check session if available
|
|
633
|
+
session_mgr = await get_session_manager(request)
|
|
634
|
+
session = None
|
|
635
|
+
if session_mgr:
|
|
636
|
+
session = await session_mgr.get_session_by_refresh_token(old_refresh_jti)
|
|
637
|
+
if not session or not session.get("active"):
|
|
638
|
+
logger.warning(
|
|
639
|
+
f"refresh_access_token: Session not found or inactive for {old_refresh_jti}"
|
|
640
|
+
)
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
# Validate session fingerprint if enabled
|
|
644
|
+
from .config_helpers import get_session_fingerprinting_config
|
|
645
|
+
|
|
646
|
+
fingerprinting_config = get_session_fingerprinting_config(request)
|
|
647
|
+
if fingerprinting_config.get("enabled", True) and fingerprinting_config.get(
|
|
648
|
+
"validate_on_refresh", True
|
|
649
|
+
):
|
|
650
|
+
stored_fingerprint = session.get("session_fingerprint")
|
|
651
|
+
if stored_fingerprint:
|
|
652
|
+
from .utils import generate_session_fingerprint
|
|
653
|
+
|
|
654
|
+
device_id = device_id or request.cookies.get("device_id")
|
|
655
|
+
if device_id:
|
|
656
|
+
current_fingerprint = generate_session_fingerprint(
|
|
657
|
+
request, device_id
|
|
658
|
+
)
|
|
659
|
+
if current_fingerprint != stored_fingerprint:
|
|
660
|
+
logger.warning(
|
|
661
|
+
f"refresh_access_token: Session fingerprint mismatch "
|
|
662
|
+
f"for user {user_id}"
|
|
663
|
+
)
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
# Prepare user data for new tokens
|
|
667
|
+
user_data = {
|
|
668
|
+
"user_id": user_id,
|
|
669
|
+
"email": refresh_token_payload.get("email"),
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
# Use existing device_id or generate new one
|
|
673
|
+
if not device_id:
|
|
674
|
+
device_id = (
|
|
675
|
+
str(uuid.uuid4()) if not device_info else device_info.get("device_id")
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if device_info:
|
|
679
|
+
device_info["device_id"] = device_id
|
|
680
|
+
else:
|
|
681
|
+
device_info = {"device_id": device_id}
|
|
682
|
+
|
|
683
|
+
# Generate new token pair
|
|
684
|
+
access_token, new_refresh_token, token_metadata = generate_token_pair(
|
|
685
|
+
user_data, str(SECRET_KEY), device_info=device_info
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# If rotation enabled, revoke old refresh token
|
|
689
|
+
if TOKEN_ROTATION_ENABLED and old_refresh_jti:
|
|
690
|
+
blacklist = await get_token_blacklist(request)
|
|
691
|
+
if blacklist:
|
|
692
|
+
# Get expiry from old token
|
|
693
|
+
from ..config import REFRESH_TOKEN_TTL
|
|
694
|
+
|
|
695
|
+
expires_at = datetime.utcnow() + timedelta(seconds=REFRESH_TOKEN_TTL)
|
|
696
|
+
await blacklist.revoke_token(
|
|
697
|
+
old_refresh_jti,
|
|
698
|
+
user_id=user_id,
|
|
699
|
+
expires_at=expires_at,
|
|
700
|
+
reason="token_rotation",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Revoke old session if rotation enabled
|
|
704
|
+
if session_mgr:
|
|
705
|
+
await session_mgr.revoke_session_by_refresh_token(old_refresh_jti)
|
|
706
|
+
|
|
707
|
+
# Create or update session with new refresh token
|
|
708
|
+
if session_mgr:
|
|
709
|
+
new_refresh_jti = token_metadata.get("refresh_jti")
|
|
710
|
+
ip_address = request.client.host if request.client else None
|
|
711
|
+
|
|
712
|
+
from .utils import generate_session_fingerprint
|
|
713
|
+
|
|
714
|
+
new_fingerprint = (
|
|
715
|
+
generate_session_fingerprint(request, device_id) if device_id else None
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
if old_refresh_jti and TOKEN_ROTATION_ENABLED:
|
|
719
|
+
update_data = {
|
|
720
|
+
"refresh_jti": new_refresh_jti,
|
|
721
|
+
"last_seen": datetime.utcnow(),
|
|
722
|
+
"ip_address": ip_address,
|
|
723
|
+
}
|
|
724
|
+
if new_fingerprint:
|
|
725
|
+
update_data["session_fingerprint"] = new_fingerprint
|
|
726
|
+
await session_mgr.collection.update_one(
|
|
727
|
+
{"refresh_jti": old_refresh_jti}, {"$set": update_data}
|
|
728
|
+
)
|
|
729
|
+
else:
|
|
730
|
+
await session_mgr.create_session(
|
|
731
|
+
user_id=user_id,
|
|
732
|
+
device_id=device_id,
|
|
733
|
+
refresh_jti=new_refresh_jti,
|
|
734
|
+
device_info=device_info,
|
|
735
|
+
ip_address=ip_address,
|
|
736
|
+
session_fingerprint=new_fingerprint,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
logger.debug(f"refresh_access_token: New tokens generated for user {user_id}")
|
|
740
|
+
return access_token, new_refresh_token, token_metadata
|
|
741
|
+
except (ValueError, TypeError, jwt.InvalidTokenError):
|
|
742
|
+
logger.exception("Validation error refreshing token")
|
|
743
|
+
return None
|
|
744
|
+
except Exception:
|
|
745
|
+
logger.exception("Unexpected error refreshing token")
|
|
746
|
+
# Re-raise unexpected errors for debugging
|
|
747
|
+
raise
|