mcli-framework 7.1.1__py3-none-any.whl → 7.1.2__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.
Potentially problematic release.
This version of mcli-framework might be problematic. Click here for more details.
- mcli/app/completion_cmd.py +59 -49
- mcli/app/completion_helpers.py +60 -138
- mcli/app/logs_cmd.py +6 -2
- mcli/app/main.py +17 -14
- mcli/app/model_cmd.py +19 -4
- mcli/chat/chat.py +3 -2
- mcli/lib/search/cached_vectorizer.py +1 -0
- mcli/lib/services/data_pipeline.py +12 -5
- mcli/lib/services/lsh_client.py +68 -57
- mcli/ml/api/app.py +28 -36
- mcli/ml/api/middleware.py +8 -16
- mcli/ml/api/routers/admin_router.py +3 -1
- mcli/ml/api/routers/auth_router.py +32 -56
- mcli/ml/api/routers/backtest_router.py +3 -1
- mcli/ml/api/routers/data_router.py +3 -1
- mcli/ml/api/routers/model_router.py +35 -74
- mcli/ml/api/routers/monitoring_router.py +3 -1
- mcli/ml/api/routers/portfolio_router.py +3 -1
- mcli/ml/api/routers/prediction_router.py +60 -65
- mcli/ml/api/routers/trade_router.py +6 -2
- mcli/ml/api/routers/websocket_router.py +12 -9
- mcli/ml/api/schemas.py +10 -2
- mcli/ml/auth/auth_manager.py +49 -114
- mcli/ml/auth/models.py +30 -15
- mcli/ml/auth/permissions.py +12 -19
- mcli/ml/backtesting/backtest_engine.py +134 -108
- mcli/ml/backtesting/performance_metrics.py +142 -108
- mcli/ml/cache.py +12 -18
- mcli/ml/cli/main.py +37 -23
- mcli/ml/config/settings.py +29 -12
- mcli/ml/dashboard/app.py +122 -130
- mcli/ml/dashboard/app_integrated.py +216 -150
- mcli/ml/dashboard/app_supabase.py +176 -108
- mcli/ml/dashboard/app_training.py +212 -206
- mcli/ml/dashboard/cli.py +14 -5
- mcli/ml/data_ingestion/api_connectors.py +51 -81
- mcli/ml/data_ingestion/data_pipeline.py +127 -125
- mcli/ml/data_ingestion/stream_processor.py +72 -80
- mcli/ml/database/migrations/env.py +3 -2
- mcli/ml/database/models.py +112 -79
- mcli/ml/database/session.py +6 -5
- mcli/ml/experimentation/ab_testing.py +149 -99
- mcli/ml/features/ensemble_features.py +9 -8
- mcli/ml/features/political_features.py +6 -5
- mcli/ml/features/recommendation_engine.py +15 -14
- mcli/ml/features/stock_features.py +7 -6
- mcli/ml/features/test_feature_engineering.py +8 -7
- mcli/ml/logging.py +10 -15
- mcli/ml/mlops/data_versioning.py +57 -64
- mcli/ml/mlops/experiment_tracker.py +49 -41
- mcli/ml/mlops/model_serving.py +59 -62
- mcli/ml/mlops/pipeline_orchestrator.py +203 -149
- mcli/ml/models/base_models.py +8 -7
- mcli/ml/models/ensemble_models.py +6 -5
- mcli/ml/models/recommendation_models.py +7 -6
- mcli/ml/models/test_models.py +18 -14
- mcli/ml/monitoring/drift_detection.py +95 -74
- mcli/ml/monitoring/metrics.py +10 -22
- mcli/ml/optimization/portfolio_optimizer.py +172 -132
- mcli/ml/predictions/prediction_engine.py +62 -50
- mcli/ml/preprocessing/data_cleaners.py +6 -5
- mcli/ml/preprocessing/feature_extractors.py +7 -6
- mcli/ml/preprocessing/ml_pipeline.py +3 -2
- mcli/ml/preprocessing/politician_trading_preprocessor.py +11 -10
- mcli/ml/preprocessing/test_preprocessing.py +4 -4
- mcli/ml/scripts/populate_sample_data.py +36 -16
- mcli/ml/tasks.py +82 -83
- mcli/ml/tests/test_integration.py +86 -76
- mcli/ml/tests/test_training_dashboard.py +169 -142
- mcli/mygroup/test_cmd.py +2 -1
- mcli/self/self_cmd.py +31 -16
- mcli/self/test_cmd.py +2 -1
- mcli/workflow/dashboard/dashboard_cmd.py +13 -6
- mcli/workflow/lsh_integration.py +46 -58
- mcli/workflow/politician_trading/commands.py +576 -427
- mcli/workflow/politician_trading/config.py +7 -7
- mcli/workflow/politician_trading/connectivity.py +35 -33
- mcli/workflow/politician_trading/data_sources.py +72 -71
- mcli/workflow/politician_trading/database.py +18 -16
- mcli/workflow/politician_trading/demo.py +4 -3
- mcli/workflow/politician_trading/models.py +5 -5
- mcli/workflow/politician_trading/monitoring.py +13 -13
- mcli/workflow/politician_trading/scrapers.py +332 -224
- mcli/workflow/politician_trading/scrapers_california.py +116 -94
- mcli/workflow/politician_trading/scrapers_eu.py +70 -71
- mcli/workflow/politician_trading/scrapers_uk.py +118 -90
- mcli/workflow/politician_trading/scrapers_us_states.py +125 -92
- mcli/workflow/politician_trading/workflow.py +98 -71
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/METADATA +1 -1
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/RECORD +94 -94
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/WHEEL +0 -0
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/licenses/LICENSE +0 -0
- {mcli_framework-7.1.1.dist-info → mcli_framework-7.1.2.dist-info}/top_level.txt +0 -0
mcli/ml/auth/auth_manager.py
CHANGED
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import secrets
|
|
4
4
|
from datetime import datetime, timedelta
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Any, Dict, Optional, Union
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
8
|
import bcrypt
|
|
9
9
|
import jwt
|
|
10
|
-
from fastapi import Depends, HTTPException,
|
|
11
|
-
from fastapi.security import
|
|
12
|
-
from sqlalchemy.orm import Session
|
|
10
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
11
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
13
12
|
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
14
|
|
|
15
15
|
from mcli.ml.config import settings
|
|
16
16
|
from mcli.ml.database.models import User, UserRole
|
|
17
17
|
from mcli.ml.database.session import get_db
|
|
18
|
-
from .models import UserCreate, UserLogin, TokenResponse, TokenData, UserResponse
|
|
19
18
|
|
|
19
|
+
from .models import TokenData, TokenResponse, UserCreate, UserLogin, UserResponse
|
|
20
20
|
|
|
21
21
|
# Security scheme
|
|
22
22
|
security = HTTPBearer()
|
|
@@ -34,22 +34,15 @@ class AuthManager:
|
|
|
34
34
|
def hash_password(self, password: str) -> str:
|
|
35
35
|
"""Hash a password using bcrypt"""
|
|
36
36
|
salt = bcrypt.gensalt()
|
|
37
|
-
hashed = bcrypt.hashpw(password.encode(
|
|
38
|
-
return hashed.decode(
|
|
37
|
+
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
|
|
38
|
+
return hashed.decode("utf-8")
|
|
39
39
|
|
|
40
40
|
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
|
41
41
|
"""Verify a password against a hash"""
|
|
42
|
-
return bcrypt.checkpw(
|
|
43
|
-
plain_password.encode('utf-8'),
|
|
44
|
-
hashed_password.encode('utf-8')
|
|
45
|
-
)
|
|
42
|
+
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
46
43
|
|
|
47
44
|
def create_access_token(
|
|
48
|
-
self,
|
|
49
|
-
user_id: str,
|
|
50
|
-
username: str,
|
|
51
|
-
role: str,
|
|
52
|
-
expires_delta: Optional[timedelta] = None
|
|
45
|
+
self, user_id: str, username: str, role: str, expires_delta: Optional[timedelta] = None
|
|
53
46
|
) -> str:
|
|
54
47
|
"""Create a JWT access token"""
|
|
55
48
|
if expires_delta:
|
|
@@ -69,11 +62,7 @@ class AuthManager:
|
|
|
69
62
|
encoded_jwt = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
|
70
63
|
return encoded_jwt
|
|
71
64
|
|
|
72
|
-
def create_refresh_token(
|
|
73
|
-
self,
|
|
74
|
-
user_id: str,
|
|
75
|
-
expires_delta: Optional[timedelta] = None
|
|
76
|
-
) -> str:
|
|
65
|
+
def create_refresh_token(self, user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
|
77
66
|
"""Create a refresh token"""
|
|
78
67
|
if expires_delta:
|
|
79
68
|
expire = datetime.utcnow() + expires_delta
|
|
@@ -94,11 +83,7 @@ class AuthManager:
|
|
|
94
83
|
def verify_token(self, token: str) -> Optional[TokenData]:
|
|
95
84
|
"""Verify and decode a JWT token"""
|
|
96
85
|
try:
|
|
97
|
-
payload = jwt.decode(
|
|
98
|
-
token,
|
|
99
|
-
self.secret_key,
|
|
100
|
-
algorithms=[self.algorithm]
|
|
101
|
-
)
|
|
86
|
+
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
|
102
87
|
|
|
103
88
|
token_data = TokenData(
|
|
104
89
|
sub=payload.get("sub"),
|
|
@@ -124,28 +109,23 @@ class AuthManager:
|
|
|
124
109
|
headers={"WWW-Authenticate": "Bearer"},
|
|
125
110
|
)
|
|
126
111
|
|
|
127
|
-
async def register_user(
|
|
128
|
-
self,
|
|
129
|
-
user_data: UserCreate,
|
|
130
|
-
db: Session
|
|
131
|
-
) -> User:
|
|
112
|
+
async def register_user(self, user_data: UserCreate, db: Session) -> User:
|
|
132
113
|
"""Register a new user"""
|
|
133
114
|
# Check if user already exists
|
|
134
|
-
existing_user =
|
|
135
|
-
(User
|
|
136
|
-
(User.email == user_data.email)
|
|
137
|
-
|
|
115
|
+
existing_user = (
|
|
116
|
+
db.query(User)
|
|
117
|
+
.filter((User.username == user_data.username) | (User.email == user_data.email))
|
|
118
|
+
.first()
|
|
119
|
+
)
|
|
138
120
|
|
|
139
121
|
if existing_user:
|
|
140
122
|
if existing_user.username == user_data.username:
|
|
141
123
|
raise HTTPException(
|
|
142
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
143
|
-
detail="Username already registered"
|
|
124
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered"
|
|
144
125
|
)
|
|
145
126
|
else:
|
|
146
127
|
raise HTTPException(
|
|
147
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
148
|
-
detail="Email already registered"
|
|
128
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
|
149
129
|
)
|
|
150
130
|
|
|
151
131
|
# Create new user
|
|
@@ -168,15 +148,9 @@ class AuthManager:
|
|
|
168
148
|
|
|
169
149
|
return new_user
|
|
170
150
|
|
|
171
|
-
async def authenticate_user(
|
|
172
|
-
self,
|
|
173
|
-
login_data: UserLogin,
|
|
174
|
-
db: Session
|
|
175
|
-
) -> Optional[User]:
|
|
151
|
+
async def authenticate_user(self, login_data: UserLogin, db: Session) -> Optional[User]:
|
|
176
152
|
"""Authenticate a user"""
|
|
177
|
-
user = db.query(User).filter(
|
|
178
|
-
User.username == login_data.username
|
|
179
|
-
).first()
|
|
153
|
+
user = db.query(User).filter(User.username == login_data.username).first()
|
|
180
154
|
|
|
181
155
|
if not user:
|
|
182
156
|
return None
|
|
@@ -190,11 +164,7 @@ class AuthManager:
|
|
|
190
164
|
|
|
191
165
|
return user
|
|
192
166
|
|
|
193
|
-
async def login(
|
|
194
|
-
self,
|
|
195
|
-
login_data: UserLogin,
|
|
196
|
-
db: Session
|
|
197
|
-
) -> TokenResponse:
|
|
167
|
+
async def login(self, login_data: UserLogin, db: Session) -> TokenResponse:
|
|
198
168
|
"""Login user and return tokens"""
|
|
199
169
|
user = await self.authenticate_user(login_data, db)
|
|
200
170
|
|
|
@@ -207,15 +177,12 @@ class AuthManager:
|
|
|
207
177
|
|
|
208
178
|
if not user.is_active:
|
|
209
179
|
raise HTTPException(
|
|
210
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
211
|
-
detail="User account is disabled"
|
|
180
|
+
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
|
212
181
|
)
|
|
213
182
|
|
|
214
183
|
# Create tokens
|
|
215
184
|
access_token = self.create_access_token(
|
|
216
|
-
user_id=str(user.id),
|
|
217
|
-
username=user.username,
|
|
218
|
-
role=user.role.value
|
|
185
|
+
user_id=str(user.id), username=user.username, role=user.role.value
|
|
219
186
|
)
|
|
220
187
|
|
|
221
188
|
refresh_token = self.create_refresh_token(user_id=str(user.id))
|
|
@@ -224,26 +191,17 @@ class AuthManager:
|
|
|
224
191
|
access_token=access_token,
|
|
225
192
|
refresh_token=refresh_token,
|
|
226
193
|
expires_in=self.access_token_expire_minutes * 60,
|
|
227
|
-
user=UserResponse.from_orm(user)
|
|
194
|
+
user=UserResponse.from_orm(user),
|
|
228
195
|
)
|
|
229
196
|
|
|
230
|
-
async def refresh_access_token(
|
|
231
|
-
self,
|
|
232
|
-
refresh_token: str,
|
|
233
|
-
db: Session
|
|
234
|
-
) -> TokenResponse:
|
|
197
|
+
async def refresh_access_token(self, refresh_token: str, db: Session) -> TokenResponse:
|
|
235
198
|
"""Refresh access token using refresh token"""
|
|
236
199
|
try:
|
|
237
|
-
payload = jwt.decode(
|
|
238
|
-
refresh_token,
|
|
239
|
-
self.secret_key,
|
|
240
|
-
algorithms=[self.algorithm]
|
|
241
|
-
)
|
|
200
|
+
payload = jwt.decode(refresh_token, self.secret_key, algorithms=[self.algorithm])
|
|
242
201
|
|
|
243
202
|
if payload.get("type") != "refresh":
|
|
244
203
|
raise HTTPException(
|
|
245
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
246
|
-
detail="Invalid refresh token"
|
|
204
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
|
247
205
|
)
|
|
248
206
|
|
|
249
207
|
user_id = payload.get("sub")
|
|
@@ -251,39 +209,34 @@ class AuthManager:
|
|
|
251
209
|
|
|
252
210
|
if not user or not user.is_active:
|
|
253
211
|
raise HTTPException(
|
|
254
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
255
|
-
detail="User not found or disabled"
|
|
212
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or disabled"
|
|
256
213
|
)
|
|
257
214
|
|
|
258
215
|
# Create new access token
|
|
259
216
|
access_token = self.create_access_token(
|
|
260
|
-
user_id=str(user.id),
|
|
261
|
-
username=user.username,
|
|
262
|
-
role=user.role.value
|
|
217
|
+
user_id=str(user.id), username=user.username, role=user.role.value
|
|
263
218
|
)
|
|
264
219
|
|
|
265
220
|
return TokenResponse(
|
|
266
221
|
access_token=access_token,
|
|
267
222
|
refresh_token=refresh_token, # Return same refresh token
|
|
268
223
|
expires_in=self.access_token_expire_minutes * 60,
|
|
269
|
-
user=UserResponse.from_orm(user)
|
|
224
|
+
user=UserResponse.from_orm(user),
|
|
270
225
|
)
|
|
271
226
|
|
|
272
227
|
except jwt.ExpiredSignatureError:
|
|
273
228
|
raise HTTPException(
|
|
274
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
275
|
-
detail="Refresh token has expired"
|
|
229
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token has expired"
|
|
276
230
|
)
|
|
277
231
|
except jwt.JWTError:
|
|
278
232
|
raise HTTPException(
|
|
279
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
280
|
-
detail="Invalid refresh token"
|
|
233
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
|
281
234
|
)
|
|
282
235
|
|
|
283
236
|
async def get_current_user(
|
|
284
237
|
self,
|
|
285
238
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
286
|
-
db: Session = Depends(get_db)
|
|
239
|
+
db: Session = Depends(get_db),
|
|
287
240
|
) -> User:
|
|
288
241
|
"""Get current authenticated user from JWT token"""
|
|
289
242
|
token = credentials.credentials
|
|
@@ -293,28 +246,22 @@ class AuthManager:
|
|
|
293
246
|
user = db.query(User).filter(User.id == token_data.sub).first()
|
|
294
247
|
|
|
295
248
|
if not user:
|
|
296
|
-
raise HTTPException(
|
|
297
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
298
|
-
detail="User not found"
|
|
299
|
-
)
|
|
249
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
300
250
|
|
|
301
251
|
if not user.is_active:
|
|
302
252
|
raise HTTPException(
|
|
303
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
304
|
-
detail="User account is disabled"
|
|
253
|
+
status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled"
|
|
305
254
|
)
|
|
306
255
|
|
|
307
256
|
return user
|
|
308
257
|
|
|
309
258
|
def require_role(self, *allowed_roles: UserRole):
|
|
310
259
|
"""Decorator/dependency to require specific roles"""
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
) -> User:
|
|
260
|
+
|
|
261
|
+
async def role_checker(current_user: User = Depends(self.get_current_user)) -> User:
|
|
314
262
|
if current_user.role not in allowed_roles:
|
|
315
263
|
raise HTTPException(
|
|
316
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
317
|
-
detail="Insufficient permissions"
|
|
264
|
+
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
|
|
318
265
|
)
|
|
319
266
|
return current_user
|
|
320
267
|
|
|
@@ -333,34 +280,25 @@ get_current_user = auth_manager.get_current_user
|
|
|
333
280
|
require_role = auth_manager.require_role
|
|
334
281
|
|
|
335
282
|
|
|
336
|
-
async def get_current_active_user(
|
|
337
|
-
current_user: User = Depends(get_current_user)
|
|
338
|
-
) -> User:
|
|
283
|
+
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
|
339
284
|
"""Get current active user"""
|
|
340
285
|
if not current_user.is_active:
|
|
341
|
-
raise HTTPException(
|
|
342
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
343
|
-
detail="Inactive user"
|
|
344
|
-
)
|
|
286
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
|
|
345
287
|
return current_user
|
|
346
288
|
|
|
347
289
|
|
|
348
|
-
async def get_admin_user(
|
|
349
|
-
current_user: User = Depends(get_current_user)
|
|
350
|
-
) -> User:
|
|
290
|
+
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
|
351
291
|
"""Get current admin user"""
|
|
352
292
|
if current_user.role != UserRole.ADMIN:
|
|
353
|
-
raise HTTPException(
|
|
354
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
355
|
-
detail="Admin access required"
|
|
356
|
-
)
|
|
293
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
357
294
|
return current_user
|
|
358
295
|
|
|
359
296
|
|
|
297
|
+
import asyncio
|
|
298
|
+
|
|
360
299
|
# Rate limiting
|
|
361
300
|
from collections import defaultdict
|
|
362
301
|
from datetime import datetime, timedelta
|
|
363
|
-
import asyncio
|
|
364
302
|
|
|
365
303
|
|
|
366
304
|
class RateLimiter:
|
|
@@ -379,8 +317,7 @@ class RateLimiter:
|
|
|
379
317
|
|
|
380
318
|
# Clean old requests
|
|
381
319
|
self.clients[client_id] = [
|
|
382
|
-
req_time for req_time in self.clients[client_id]
|
|
383
|
-
if req_time > minute_ago
|
|
320
|
+
req_time for req_time in self.clients[client_id] if req_time > minute_ago
|
|
384
321
|
]
|
|
385
322
|
|
|
386
323
|
# Check limit
|
|
@@ -400,8 +337,7 @@ class RateLimiter:
|
|
|
400
337
|
|
|
401
338
|
for client_id in list(self.clients.keys()):
|
|
402
339
|
self.clients[client_id] = [
|
|
403
|
-
req_time for req_time in self.clients[client_id]
|
|
404
|
-
if req_time > window_start
|
|
340
|
+
req_time for req_time in self.clients[client_id] if req_time > window_start
|
|
405
341
|
]
|
|
406
342
|
|
|
407
343
|
if not self.clients[client_id]:
|
|
@@ -418,8 +354,7 @@ async def check_rate_limit(request: Request):
|
|
|
418
354
|
|
|
419
355
|
if not await rate_limiter.check_rate_limit(client_ip):
|
|
420
356
|
raise HTTPException(
|
|
421
|
-
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
422
|
-
detail="Rate limit exceeded"
|
|
357
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Rate limit exceeded"
|
|
423
358
|
)
|
|
424
359
|
|
|
425
|
-
return True
|
|
360
|
+
return True
|
mcli/ml/auth/models.py
CHANGED
|
@@ -1,41 +1,45 @@
|
|
|
1
1
|
"""Authentication data models"""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
-
from typing import
|
|
5
|
-
from pydantic import BaseModel, EmailStr, Field, validator
|
|
4
|
+
from typing import List, Optional
|
|
6
5
|
from uuid import UUID
|
|
7
6
|
|
|
7
|
+
from pydantic import BaseModel, EmailStr, Field, validator
|
|
8
|
+
|
|
8
9
|
|
|
9
10
|
class UserCreate(BaseModel):
|
|
10
11
|
"""User registration model"""
|
|
12
|
+
|
|
11
13
|
username: str = Field(..., min_length=3, max_length=50, regex="^[a-zA-Z0-9_-]+$")
|
|
12
14
|
email: EmailStr
|
|
13
15
|
password: str = Field(..., min_length=8, max_length=100)
|
|
14
16
|
first_name: Optional[str] = Field(None, max_length=50)
|
|
15
17
|
last_name: Optional[str] = Field(None, max_length=50)
|
|
16
18
|
|
|
17
|
-
@validator(
|
|
19
|
+
@validator("password")
|
|
18
20
|
def validate_password(cls, v):
|
|
19
21
|
"""Ensure password meets security requirements"""
|
|
20
22
|
if len(v) < 8:
|
|
21
|
-
raise ValueError(
|
|
23
|
+
raise ValueError("Password must be at least 8 characters long")
|
|
22
24
|
if not any(char.isdigit() for char in v):
|
|
23
|
-
raise ValueError(
|
|
25
|
+
raise ValueError("Password must contain at least one digit")
|
|
24
26
|
if not any(char.isupper() for char in v):
|
|
25
|
-
raise ValueError(
|
|
27
|
+
raise ValueError("Password must contain at least one uppercase letter")
|
|
26
28
|
if not any(char.islower() for char in v):
|
|
27
|
-
raise ValueError(
|
|
29
|
+
raise ValueError("Password must contain at least one lowercase letter")
|
|
28
30
|
return v
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class UserLogin(BaseModel):
|
|
32
34
|
"""User login model"""
|
|
35
|
+
|
|
33
36
|
username: str
|
|
34
37
|
password: str
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class UserResponse(BaseModel):
|
|
38
41
|
"""User response model"""
|
|
42
|
+
|
|
39
43
|
id: UUID
|
|
40
44
|
username: str
|
|
41
45
|
email: str
|
|
@@ -53,6 +57,7 @@ class UserResponse(BaseModel):
|
|
|
53
57
|
|
|
54
58
|
class TokenResponse(BaseModel):
|
|
55
59
|
"""JWT token response"""
|
|
60
|
+
|
|
56
61
|
access_token: str
|
|
57
62
|
token_type: str = "Bearer"
|
|
58
63
|
expires_in: int
|
|
@@ -62,6 +67,7 @@ class TokenResponse(BaseModel):
|
|
|
62
67
|
|
|
63
68
|
class TokenData(BaseModel):
|
|
64
69
|
"""JWT token payload"""
|
|
70
|
+
|
|
65
71
|
sub: str # User ID
|
|
66
72
|
username: str
|
|
67
73
|
role: str
|
|
@@ -72,45 +78,50 @@ class TokenData(BaseModel):
|
|
|
72
78
|
|
|
73
79
|
class PasswordReset(BaseModel):
|
|
74
80
|
"""Password reset request"""
|
|
81
|
+
|
|
75
82
|
email: EmailStr
|
|
76
83
|
|
|
77
84
|
|
|
78
85
|
class PasswordResetConfirm(BaseModel):
|
|
79
86
|
"""Password reset confirmation"""
|
|
87
|
+
|
|
80
88
|
token: str
|
|
81
89
|
new_password: str = Field(..., min_length=8, max_length=100)
|
|
82
90
|
|
|
83
91
|
|
|
84
92
|
class PasswordChange(BaseModel):
|
|
85
93
|
"""Password change request"""
|
|
94
|
+
|
|
86
95
|
current_password: str
|
|
87
96
|
new_password: str = Field(..., min_length=8, max_length=100)
|
|
88
97
|
|
|
89
|
-
@validator(
|
|
98
|
+
@validator("new_password")
|
|
90
99
|
def validate_password(cls, v, values):
|
|
91
100
|
"""Ensure new password is different and meets requirements"""
|
|
92
|
-
if
|
|
93
|
-
raise ValueError(
|
|
101
|
+
if "current_password" in values and v == values["current_password"]:
|
|
102
|
+
raise ValueError("New password must be different from current password")
|
|
94
103
|
|
|
95
104
|
if len(v) < 8:
|
|
96
|
-
raise ValueError(
|
|
105
|
+
raise ValueError("Password must be at least 8 characters long")
|
|
97
106
|
if not any(char.isdigit() for char in v):
|
|
98
|
-
raise ValueError(
|
|
107
|
+
raise ValueError("Password must contain at least one digit")
|
|
99
108
|
if not any(char.isupper() for char in v):
|
|
100
|
-
raise ValueError(
|
|
109
|
+
raise ValueError("Password must contain at least one uppercase letter")
|
|
101
110
|
if not any(char.islower() for char in v):
|
|
102
|
-
raise ValueError(
|
|
111
|
+
raise ValueError("Password must contain at least one lowercase letter")
|
|
103
112
|
return v
|
|
104
113
|
|
|
105
114
|
|
|
106
115
|
class APIKeyCreate(BaseModel):
|
|
107
116
|
"""API key creation model"""
|
|
117
|
+
|
|
108
118
|
name: str = Field(..., min_length=1, max_length=100)
|
|
109
119
|
expires_at: Optional[datetime] = None
|
|
110
120
|
|
|
111
121
|
|
|
112
122
|
class APIKeyResponse(BaseModel):
|
|
113
123
|
"""API key response"""
|
|
124
|
+
|
|
114
125
|
key: str
|
|
115
126
|
name: str
|
|
116
127
|
created_at: datetime
|
|
@@ -120,6 +131,7 @@ class APIKeyResponse(BaseModel):
|
|
|
120
131
|
|
|
121
132
|
class UserUpdate(BaseModel):
|
|
122
133
|
"""User update model"""
|
|
134
|
+
|
|
123
135
|
email: Optional[EmailStr] = None
|
|
124
136
|
first_name: Optional[str] = Field(None, max_length=50)
|
|
125
137
|
last_name: Optional[str] = Field(None, max_length=50)
|
|
@@ -129,6 +141,7 @@ class UserUpdate(BaseModel):
|
|
|
129
141
|
|
|
130
142
|
class UserPermissions(BaseModel):
|
|
131
143
|
"""User permissions model"""
|
|
144
|
+
|
|
132
145
|
user_id: UUID
|
|
133
146
|
permissions: List[str]
|
|
134
147
|
roles: List[str]
|
|
@@ -136,6 +149,7 @@ class UserPermissions(BaseModel):
|
|
|
136
149
|
|
|
137
150
|
class SessionInfo(BaseModel):
|
|
138
151
|
"""Session information"""
|
|
152
|
+
|
|
139
153
|
session_id: str
|
|
140
154
|
user_id: UUID
|
|
141
155
|
ip_address: str
|
|
@@ -147,8 +161,9 @@ class SessionInfo(BaseModel):
|
|
|
147
161
|
|
|
148
162
|
class LoginAttempt(BaseModel):
|
|
149
163
|
"""Login attempt tracking"""
|
|
164
|
+
|
|
150
165
|
username: str
|
|
151
166
|
ip_address: str
|
|
152
167
|
success: bool
|
|
153
168
|
timestamp: datetime
|
|
154
|
-
failure_reason: Optional[str] = None
|
|
169
|
+
failure_reason: Optional[str] = None
|
mcli/ml/auth/permissions.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from enum import Enum
|
|
5
|
-
from typing import List, Set, Dict, Any
|
|
6
5
|
from functools import wraps
|
|
6
|
+
from typing import Any, Dict, List, Set
|
|
7
7
|
|
|
8
8
|
from fastapi import HTTPException, status
|
|
9
9
|
from sqlalchemy.orm import Session
|
|
@@ -60,7 +60,6 @@ class Permission(Enum):
|
|
|
60
60
|
# Role-based permission mapping
|
|
61
61
|
ROLE_PERMISSIONS: Dict[UserRole, Set[Permission]] = {
|
|
62
62
|
UserRole.ADMIN: set(Permission), # Admin has all permissions
|
|
63
|
-
|
|
64
63
|
UserRole.ANALYST: {
|
|
65
64
|
Permission.MODEL_VIEW,
|
|
66
65
|
Permission.MODEL_CREATE,
|
|
@@ -76,7 +75,6 @@ ROLE_PERMISSIONS: Dict[UserRole, Set[Permission]] = {
|
|
|
76
75
|
Permission.DATA_EDIT,
|
|
77
76
|
Permission.SYSTEM_STATUS,
|
|
78
77
|
},
|
|
79
|
-
|
|
80
78
|
UserRole.USER: {
|
|
81
79
|
Permission.MODEL_VIEW,
|
|
82
80
|
Permission.PREDICTION_VIEW,
|
|
@@ -88,7 +86,6 @@ ROLE_PERMISSIONS: Dict[UserRole, Set[Permission]] = {
|
|
|
88
86
|
Permission.DATA_VIEW,
|
|
89
87
|
Permission.SYSTEM_STATUS,
|
|
90
88
|
},
|
|
91
|
-
|
|
92
89
|
UserRole.VIEWER: {
|
|
93
90
|
Permission.MODEL_VIEW,
|
|
94
91
|
Permission.PREDICTION_VIEW,
|
|
@@ -109,8 +106,7 @@ def check_permission(user: User, permission: Permission) -> None:
|
|
|
109
106
|
"""Check permission and raise exception if not allowed"""
|
|
110
107
|
if not has_permission(user, permission):
|
|
111
108
|
raise HTTPException(
|
|
112
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
113
|
-
detail=f"Permission denied: {permission.value}"
|
|
109
|
+
status_code=status.HTTP_403_FORBIDDEN, detail=f"Permission denied: {permission.value}"
|
|
114
110
|
)
|
|
115
111
|
|
|
116
112
|
|
|
@@ -121,12 +117,11 @@ def require_permission(permission: Permission):
|
|
|
121
117
|
@wraps(func)
|
|
122
118
|
async def wrapper(*args, **kwargs):
|
|
123
119
|
# Extract user from kwargs (assumes it's passed as current_user)
|
|
124
|
-
current_user = kwargs.get(
|
|
120
|
+
current_user = kwargs.get("current_user")
|
|
125
121
|
|
|
126
122
|
if not current_user:
|
|
127
123
|
raise HTTPException(
|
|
128
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
129
|
-
detail="Authentication required"
|
|
124
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
|
130
125
|
)
|
|
131
126
|
|
|
132
127
|
check_permission(current_user, permission)
|
|
@@ -143,18 +138,17 @@ def require_any_permission(*permissions: Permission):
|
|
|
143
138
|
def decorator(func):
|
|
144
139
|
@wraps(func)
|
|
145
140
|
async def wrapper(*args, **kwargs):
|
|
146
|
-
current_user = kwargs.get(
|
|
141
|
+
current_user = kwargs.get("current_user")
|
|
147
142
|
|
|
148
143
|
if not current_user:
|
|
149
144
|
raise HTTPException(
|
|
150
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
151
|
-
detail="Authentication required"
|
|
145
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
|
152
146
|
)
|
|
153
147
|
|
|
154
148
|
if not any(has_permission(current_user, p) for p in permissions):
|
|
155
149
|
raise HTTPException(
|
|
156
150
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
157
|
-
detail=f"Permission denied. Required: {[p.value for p in permissions]}"
|
|
151
|
+
detail=f"Permission denied. Required: {[p.value for p in permissions]}",
|
|
158
152
|
)
|
|
159
153
|
|
|
160
154
|
return await func(*args, **kwargs)
|
|
@@ -170,12 +164,11 @@ def require_all_permissions(*permissions: Permission):
|
|
|
170
164
|
def decorator(func):
|
|
171
165
|
@wraps(func)
|
|
172
166
|
async def wrapper(*args, **kwargs):
|
|
173
|
-
current_user = kwargs.get(
|
|
167
|
+
current_user = kwargs.get("current_user")
|
|
174
168
|
|
|
175
169
|
if not current_user:
|
|
176
170
|
raise HTTPException(
|
|
177
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
178
|
-
detail="Authentication required"
|
|
171
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
|
179
172
|
)
|
|
180
173
|
|
|
181
174
|
for permission in permissions:
|
|
@@ -257,7 +250,7 @@ class AuditLogger:
|
|
|
257
250
|
action: str,
|
|
258
251
|
success: bool,
|
|
259
252
|
details: Dict[str, Any] = None,
|
|
260
|
-
db: Session = None
|
|
253
|
+
db: Session = None,
|
|
261
254
|
):
|
|
262
255
|
"""Log access attempt"""
|
|
263
256
|
log_entry = {
|
|
@@ -267,7 +260,7 @@ class AuditLogger:
|
|
|
267
260
|
"action": action,
|
|
268
261
|
"success": success,
|
|
269
262
|
"timestamp": datetime.utcnow(),
|
|
270
|
-
"details": details or {}
|
|
263
|
+
"details": details or {},
|
|
271
264
|
}
|
|
272
265
|
|
|
273
266
|
# In production, save to database or logging service
|
|
@@ -299,4 +292,4 @@ class PermissionGroup:
|
|
|
299
292
|
Permission.DATA_EDIT,
|
|
300
293
|
}
|
|
301
294
|
|
|
302
|
-
ADMIN = set(Permission) # All permissions
|
|
295
|
+
ADMIN = set(Permission) # All permissions
|