squirrels 0.5.0b4__py3-none-any.whl → 0.5.1__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 squirrels might be problematic. Click here for more details.
- squirrels/__init__.py +2 -0
- squirrels/_api_routes/auth.py +83 -74
- squirrels/_api_routes/base.py +58 -41
- squirrels/_api_routes/dashboards.py +37 -21
- squirrels/_api_routes/data_management.py +72 -27
- squirrels/_api_routes/datasets.py +107 -84
- squirrels/_api_routes/oauth2.py +11 -13
- squirrels/_api_routes/project.py +71 -33
- squirrels/_api_server.py +130 -63
- squirrels/_arguments/run_time_args.py +9 -9
- squirrels/_auth.py +117 -162
- squirrels/_command_line.py +68 -32
- squirrels/_compile_prompts.py +147 -0
- squirrels/_connection_set.py +11 -2
- squirrels/_constants.py +22 -8
- squirrels/_data_sources.py +38 -32
- squirrels/_dataset_types.py +2 -4
- squirrels/_initializer.py +1 -1
- squirrels/_logging.py +117 -0
- squirrels/_manifest.py +125 -58
- squirrels/_model_builder.py +10 -54
- squirrels/_models.py +224 -108
- squirrels/_package_data/base_project/.env +15 -4
- squirrels/_package_data/base_project/.env.example +14 -3
- squirrels/_package_data/base_project/connections.yml +4 -3
- squirrels/_package_data/base_project/dashboards/dashboard_example.py +2 -2
- squirrels/_package_data/base_project/dashboards/dashboard_example.yml +4 -4
- squirrels/_package_data/base_project/duckdb_init.sql +1 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +7 -2
- squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +16 -10
- squirrels/_package_data/base_project/models/federates/federate_example.py +22 -15
- squirrels/_package_data/base_project/models/federates/federate_example.sql +3 -7
- squirrels/_package_data/base_project/models/federates/federate_example.yml +1 -1
- squirrels/_package_data/base_project/models/sources.yml +5 -6
- squirrels/_package_data/base_project/parameters.yml +24 -38
- squirrels/_package_data/base_project/pyconfigs/connections.py +5 -1
- squirrels/_package_data/base_project/pyconfigs/context.py +23 -12
- squirrels/_package_data/base_project/pyconfigs/parameters.py +68 -33
- squirrels/_package_data/base_project/pyconfigs/user.py +11 -18
- squirrels/_package_data/base_project/seeds/seed_categories.yml +1 -1
- squirrels/_package_data/base_project/seeds/seed_subcategories.yml +1 -1
- squirrels/_package_data/base_project/squirrels.yml.j2 +18 -28
- squirrels/_package_data/templates/squirrels_studio.html +20 -0
- squirrels/_parameter_configs.py +43 -22
- squirrels/_parameter_options.py +1 -1
- squirrels/_parameter_sets.py +8 -10
- squirrels/_project.py +351 -234
- squirrels/_request_context.py +33 -0
- squirrels/_schemas/auth_models.py +32 -9
- squirrels/_schemas/query_param_models.py +9 -1
- squirrels/_schemas/response_models.py +36 -10
- squirrels/_seeds.py +1 -1
- squirrels/_sources.py +23 -19
- squirrels/_utils.py +83 -35
- squirrels/_version.py +1 -1
- squirrels/arguments.py +5 -0
- squirrels/auth.py +4 -1
- squirrels/connections.py +2 -0
- squirrels/dashboards.py +3 -1
- squirrels/data_sources.py +6 -0
- squirrels/parameter_options.py +5 -0
- squirrels/parameters.py +5 -0
- squirrels/types.py +6 -1
- {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/METADATA +28 -13
- squirrels-0.5.1.dist-info/RECORD +98 -0
- squirrels-0.5.0b4.dist-info/RECORD +0 -94
- {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/WHEEL +0 -0
- {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/entry_points.txt +0 -0
- {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/licenses/LICENSE +0 -0
squirrels/__init__.py
CHANGED
squirrels/_api_routes/auth.py
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Authentication and user management routes
|
|
3
3
|
"""
|
|
4
|
-
from typing import Annotated,
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
5
|
from fastapi import FastAPI, Depends, Request, Response, status, Form, APIRouter
|
|
6
6
|
from fastapi.responses import RedirectResponse
|
|
7
7
|
from fastapi.security import HTTPBearer
|
|
8
8
|
from pydantic import BaseModel, Field
|
|
9
9
|
from authlib.integrations.starlette_client import OAuth
|
|
10
10
|
|
|
11
|
-
from .. import _constants as c
|
|
12
11
|
from .._schemas import response_models as rm
|
|
13
12
|
from .._exceptions import InvalidInputError
|
|
14
|
-
from ..
|
|
13
|
+
from .._schemas.auth_models import AbstractUser, RegisteredUser, GuestUser
|
|
15
14
|
from .base import RouteBase
|
|
16
15
|
|
|
17
16
|
|
|
@@ -21,18 +20,19 @@ class AuthRoutes(RouteBase):
|
|
|
21
20
|
def __init__(self, get_bearer_token: HTTPBearer, project, no_cache: bool = False):
|
|
22
21
|
super().__init__(get_bearer_token, project, no_cache)
|
|
23
22
|
|
|
24
|
-
def setup_routes(self, app: FastAPI) -> None:
|
|
23
|
+
def setup_routes(self, app: FastAPI, squirrels_version_path: str) -> None:
|
|
25
24
|
"""Setup all authentication routes"""
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
auth_path = squirrels_version_path + "/auth"
|
|
27
|
+
auth_router = APIRouter(prefix=auth_path)
|
|
28
|
+
user_management_router = APIRouter(prefix=auth_path + "/user-management")
|
|
29
29
|
|
|
30
30
|
# Get expiry configuration
|
|
31
31
|
expiry_mins = self._get_access_token_expiry_minutes()
|
|
32
32
|
|
|
33
33
|
# Create user models
|
|
34
|
-
class UpdateUserModel(self.
|
|
35
|
-
|
|
34
|
+
class UpdateUserModel(self.authenticator.CustomUserFields):
|
|
35
|
+
access_level: Literal["admin", "member"]
|
|
36
36
|
|
|
37
37
|
class UserInfoModel(UpdateUserModel):
|
|
38
38
|
username: str
|
|
@@ -54,14 +54,15 @@ class AuthRoutes(RouteBase):
|
|
|
54
54
|
|
|
55
55
|
# User info endpoint
|
|
56
56
|
@auth_router.get("/userinfo", description="Get the authenticated user's fields", tags=["Authentication"])
|
|
57
|
-
async def get_userinfo(user:
|
|
58
|
-
if user
|
|
59
|
-
raise InvalidInputError(401, "Invalid authorization token
|
|
60
|
-
|
|
57
|
+
async def get_userinfo(user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> UserInfoModel:
|
|
58
|
+
if isinstance(user, GuestUser):
|
|
59
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, no user info found")
|
|
60
|
+
custom_fields = user.custom_fields.model_dump(mode='json')
|
|
61
|
+
return UserInfoModel(username=user.username, access_level=user.access_level, **custom_fields)
|
|
61
62
|
|
|
62
63
|
# Login helper
|
|
63
64
|
def login_helper(
|
|
64
|
-
request: Request, user:
|
|
65
|
+
request: Request, user: RegisteredUser, redirect_url: str | None, *,
|
|
65
66
|
redirect_status_code: int = status.HTTP_307_TEMPORARY_REDIRECT
|
|
66
67
|
):
|
|
67
68
|
access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
|
|
@@ -75,6 +76,8 @@ class AuthRoutes(RouteBase):
|
|
|
75
76
|
302: {"description": "Redirect if redirect URL parameter is specified"},
|
|
76
77
|
})
|
|
77
78
|
async def login(request: Request, username: Annotated[str, Form()], password: Annotated[str, Form()], redirect_url: str | None = None):
|
|
79
|
+
if self.manifest_cfg.authentication.type.value == "external":
|
|
80
|
+
raise InvalidInputError(403, "forbidden_login", "Username/password login is disabled when authentication.type is 'external'")
|
|
78
81
|
user = self.authenticator.get_user(username, password)
|
|
79
82
|
return login_helper(request, user, redirect_url, redirect_status_code=status.HTTP_302_FOUND)
|
|
80
83
|
|
|
@@ -83,10 +86,10 @@ class AuthRoutes(RouteBase):
|
|
|
83
86
|
307: {"description": "Redirect if redirect URL parameter is specified"},
|
|
84
87
|
})
|
|
85
88
|
async def login_with_api_key(
|
|
86
|
-
request: Request, redirect_url: str | None = None, user:
|
|
89
|
+
request: Request, redirect_url: str | None = None, user: RegisteredUser | GuestUser = Depends(self.get_current_user)
|
|
87
90
|
):
|
|
88
|
-
if user
|
|
89
|
-
raise InvalidInputError(401, "Invalid authorization token
|
|
91
|
+
if isinstance(user, GuestUser):
|
|
92
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, no user info found")
|
|
90
93
|
return login_helper(request, user, redirect_url)
|
|
91
94
|
|
|
92
95
|
# Provider authentication endpoints
|
|
@@ -109,7 +112,7 @@ class AuthRoutes(RouteBase):
|
|
|
109
112
|
|
|
110
113
|
@auth_router.get(provider_login_path, tags=["Authentication"])
|
|
111
114
|
async def provider_login(request: Request, provider_name: str, redirect_url: str | None = None) -> RedirectResponse:
|
|
112
|
-
"""
|
|
115
|
+
"""Redirect to the login URL for the OAuth provider"""
|
|
113
116
|
client = oauth.create_client(provider_name)
|
|
114
117
|
if client is None:
|
|
115
118
|
raise InvalidInputError(status_code=404, error="provider_not_found", error_description=f"Provider {provider_name} not found or configured.")
|
|
@@ -159,22 +162,23 @@ class AuthRoutes(RouteBase):
|
|
|
159
162
|
302: {"description": "Redirect if redirect URL parameter is specified"},
|
|
160
163
|
})
|
|
161
164
|
async def logout(request: Request, redirect_url: str | None = None):
|
|
165
|
+
"""Logout the current user, and redirect to the specified URL if provided"""
|
|
162
166
|
request.session.pop("access_token", None)
|
|
163
167
|
request.session.pop("access_token_expiry", None)
|
|
164
168
|
if redirect_url:
|
|
165
169
|
return RedirectResponse(url=redirect_url)
|
|
166
170
|
|
|
167
171
|
# Change password endpoint
|
|
168
|
-
change_password_path = '/
|
|
172
|
+
change_password_path = '/password'
|
|
169
173
|
|
|
170
174
|
class ChangePasswordRequest(BaseModel):
|
|
171
175
|
old_password: str
|
|
172
176
|
new_password: str
|
|
173
177
|
|
|
174
178
|
@auth_router.put(change_password_path, description="Change the password for the current user", tags=["Authentication"])
|
|
175
|
-
async def change_password(request: ChangePasswordRequest, user:
|
|
176
|
-
if user
|
|
177
|
-
raise InvalidInputError(401, "
|
|
179
|
+
async def change_password(request: ChangePasswordRequest, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> None:
|
|
180
|
+
if isinstance(user, GuestUser):
|
|
181
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token")
|
|
178
182
|
self.authenticator.change_password(user.username, request.old_password, request.new_password)
|
|
179
183
|
|
|
180
184
|
# API Key endpoints
|
|
@@ -188,17 +192,17 @@ class AuthRoutes(RouteBase):
|
|
|
188
192
|
)
|
|
189
193
|
|
|
190
194
|
@auth_router.post(api_key_path, description="Create a new API key for the user", tags=["Authentication"])
|
|
191
|
-
async def create_api_key(body: ApiKeyRequestBody, user:
|
|
192
|
-
if user
|
|
193
|
-
raise InvalidInputError(401, "Invalid authorization token
|
|
195
|
+
async def create_api_key(body: ApiKeyRequestBody, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> rm.ApiKeyResponse:
|
|
196
|
+
if isinstance(user, GuestUser):
|
|
197
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot create API key")
|
|
194
198
|
|
|
195
199
|
api_key, _ = self.authenticator.create_access_token(user, expiry_minutes=body.expiry_minutes, title=body.title)
|
|
196
200
|
return rm.ApiKeyResponse(api_key=api_key)
|
|
197
201
|
|
|
198
202
|
@auth_router.get(api_key_path, description="Get all API keys with title for the current user", tags=["Authentication"])
|
|
199
|
-
async def get_all_api_keys(user:
|
|
200
|
-
if user
|
|
201
|
-
raise InvalidInputError(401, "Invalid authorization token
|
|
203
|
+
async def get_all_api_keys(user: RegisteredUser | GuestUser = Depends(self.get_current_user)):
|
|
204
|
+
if isinstance(user, GuestUser):
|
|
205
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot get API keys")
|
|
202
206
|
return self.authenticator.get_all_api_keys(user.username)
|
|
203
207
|
|
|
204
208
|
revoke_api_key_path = '/api-key/{api_key_id}'
|
|
@@ -206,57 +210,62 @@ class AuthRoutes(RouteBase):
|
|
|
206
210
|
@auth_router.delete(revoke_api_key_path, description="Revoke an API key", tags=["Authentication"], responses={
|
|
207
211
|
204: { "description": "API key revoked successfully" }
|
|
208
212
|
})
|
|
209
|
-
async def revoke_api_key(api_key_id: str, user:
|
|
210
|
-
if user
|
|
211
|
-
raise InvalidInputError(401, "Invalid authorization token
|
|
213
|
+
async def revoke_api_key(api_key_id: str, user: RegisteredUser | GuestUser = Depends(self.get_current_user)) -> Response:
|
|
214
|
+
if isinstance(user, GuestUser):
|
|
215
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, cannot revoke API key")
|
|
212
216
|
self.authenticator.revoke_api_key(user.username, api_key_id)
|
|
213
217
|
return Response(status_code=204)
|
|
214
218
|
|
|
215
|
-
# User management endpoints
|
|
216
|
-
|
|
219
|
+
# User management endpoints (disabled if external auth only)
|
|
220
|
+
if self.manifest_cfg.authentication.type.value == "managed":
|
|
221
|
+
user_fields_path = '/user-fields'
|
|
217
222
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
223
|
+
@user_management_router.get(user_fields_path, description="Get details of the user fields", tags=["User Management"])
|
|
224
|
+
async def get_user_fields():
|
|
225
|
+
return self.authenticator.user_fields
|
|
226
|
+
|
|
227
|
+
add_user_path = '/users'
|
|
228
|
+
|
|
229
|
+
@user_management_router.post(add_user_path, description="Add a new user by providing details for username, password, and user fields", tags=["User Management"])
|
|
230
|
+
async def add_user(
|
|
231
|
+
new_user: AddUserModel, user: AbstractUser = Depends(self.get_current_user)
|
|
232
|
+
) -> None:
|
|
233
|
+
if user.access_level != "admin":
|
|
234
|
+
raise InvalidInputError(403, "unauthorized_to_add_user", "Current user cannot add new users")
|
|
235
|
+
self.authenticator.add_user(new_user.username, new_user.model_dump(mode='json', exclude={"username"}))
|
|
236
|
+
|
|
237
|
+
update_user_path = '/users/{username}'
|
|
238
|
+
|
|
239
|
+
@user_management_router.put(update_user_path, description="Update the user of the given username given the new user details", tags=["User Management"])
|
|
240
|
+
async def update_user(
|
|
241
|
+
username: str, updated_user: UpdateUserModel, user: AbstractUser = Depends(self.get_current_user)
|
|
242
|
+
) -> None:
|
|
243
|
+
if user.access_level != "admin":
|
|
244
|
+
raise InvalidInputError(403, "unauthorized_to_update_user", "Current user cannot update users")
|
|
245
|
+
self.authenticator.add_user(username, updated_user.model_dump(mode='json'), update_user=True)
|
|
246
|
+
|
|
247
|
+
list_users_path = '/users'
|
|
248
|
+
|
|
249
|
+
@user_management_router.get(list_users_path, tags=["User Management"])
|
|
250
|
+
async def list_all_users() -> list[UserInfoModel]:
|
|
251
|
+
registered_users = self.authenticator.get_all_users()
|
|
252
|
+
return [
|
|
253
|
+
UserInfoModel(username=user.username, access_level=user.access_level, **user.custom_fields.model_dump(mode='json'))
|
|
254
|
+
for user in registered_users
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
delete_user_path = '/users/{username}'
|
|
258
|
+
|
|
259
|
+
@user_management_router.delete(delete_user_path, tags=["User Management"], responses={
|
|
260
|
+
204: { "description": "User deleted successfully" }
|
|
261
|
+
})
|
|
262
|
+
async def delete_user(username: str, user: AbstractUser = Depends(self.get_current_user)) -> Response:
|
|
263
|
+
if user.access_level != "admin":
|
|
264
|
+
raise InvalidInputError(403, "unauthorized_to_delete_user", "Current user cannot delete users")
|
|
265
|
+
if username == user.username:
|
|
266
|
+
raise InvalidInputError(403, "cannot_delete_own_user", "Cannot delete your own user")
|
|
267
|
+
self.authenticator.delete_user(username)
|
|
268
|
+
return Response(status_code=204)
|
|
260
269
|
|
|
261
270
|
app.include_router(auth_router)
|
|
262
271
|
app.include_router(user_management_router)
|
squirrels/_api_routes/base.py
CHANGED
|
@@ -6,7 +6,6 @@ from fastapi import Request, Response, Depends
|
|
|
6
6
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
7
|
from fastapi.templating import Jinja2Templates
|
|
8
8
|
from cachetools import TTLCache
|
|
9
|
-
from pydantic import BaseModel, create_model
|
|
10
9
|
from mcp.server.fastmcp import Context
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from datetime import datetime, timezone
|
|
@@ -14,6 +13,7 @@ from datetime import datetime, timezone
|
|
|
14
13
|
from .. import _utils as u, _constants as c
|
|
15
14
|
from .._exceptions import InvalidInputError, ConfigurationError
|
|
16
15
|
from .._project import SquirrelsProject
|
|
16
|
+
from .._schemas.auth_models import GuestUser, RegisteredUser
|
|
17
17
|
|
|
18
18
|
T = TypeVar('T')
|
|
19
19
|
|
|
@@ -34,21 +34,6 @@ class RouteBase:
|
|
|
34
34
|
template_dir = Path(__file__).parent.parent / "_package_data" / "templates"
|
|
35
35
|
self.templates = Jinja2Templates(directory=str(template_dir))
|
|
36
36
|
|
|
37
|
-
# Create user models
|
|
38
|
-
fields_without_username = {
|
|
39
|
-
k: (v.annotation, v.default)
|
|
40
|
-
for k, v in self.authenticator.User.model_fields.items()
|
|
41
|
-
if k != "username"
|
|
42
|
-
}
|
|
43
|
-
self.UserModel = create_model("UserModel", __base__=BaseModel, **fields_without_username) # type: ignore
|
|
44
|
-
self.UserInfoModel = create_model("UserInfoModel", __base__=self.UserModel, username=str)
|
|
45
|
-
|
|
46
|
-
class UserInfoModel(self.UserInfoModel):
|
|
47
|
-
username: str
|
|
48
|
-
|
|
49
|
-
def __hash__(self):
|
|
50
|
-
return hash(self.username)
|
|
51
|
-
|
|
52
37
|
# Authorization dependency for current user
|
|
53
38
|
def get_token_from_session(request: Request) -> str | None:
|
|
54
39
|
expiry = request.session.get("access_token_expiry")
|
|
@@ -62,13 +47,18 @@ class RouteBase:
|
|
|
62
47
|
|
|
63
48
|
async def get_current_user(
|
|
64
49
|
request: Request, response: Response, auth: HTTPAuthorizationCredentials = Depends(get_bearer_token)
|
|
65
|
-
) ->
|
|
50
|
+
) -> GuestUser | RegisteredUser:
|
|
66
51
|
token = auth.credentials if auth and auth.scheme == "Bearer" else None
|
|
67
|
-
|
|
52
|
+
access_token = token if token else get_token_from_session(request)
|
|
53
|
+
api_key = request.headers.get("x-api-key")
|
|
54
|
+
final_token = api_key if api_key else access_token
|
|
55
|
+
|
|
68
56
|
user = self.authenticator.get_user_from_token(final_token)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
if user is None:
|
|
58
|
+
user = self.project._guest_user
|
|
59
|
+
|
|
60
|
+
response.headers["Applied-Username"] = user.username
|
|
61
|
+
return user
|
|
72
62
|
|
|
73
63
|
self.get_current_user = get_current_user
|
|
74
64
|
|
|
@@ -80,12 +70,19 @@ class RouteBase:
|
|
|
80
70
|
"depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
|
|
81
71
|
"selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
|
|
82
72
|
|
|
83
|
-
def _validate_request_params(self, all_request_params: Mapping, params: Mapping) -> None:
|
|
84
|
-
"""Validate request parameters
|
|
85
|
-
|
|
73
|
+
def _validate_request_params(self, all_request_params: Mapping, params: Mapping, headers: Mapping[str, str]) -> None:
|
|
74
|
+
"""Validate request parameters
|
|
75
|
+
|
|
76
|
+
When header 'x-verify-params' is set to a truthy value, ensure that all provided
|
|
77
|
+
query/body parameters are part of the parsed params model. Falls back to legacy
|
|
78
|
+
query param 'x_verify_params' for backward compatibility.
|
|
79
|
+
"""
|
|
80
|
+
header_val = headers.get("x-verify-params")
|
|
81
|
+
verify_params = u.to_bool(header_val) or u.to_bool(params.get("x_verify_params", False))
|
|
82
|
+
if verify_params:
|
|
86
83
|
invalid_params = [param for param in all_request_params if param not in params]
|
|
87
84
|
if invalid_params:
|
|
88
|
-
raise InvalidInputError(400, "
|
|
85
|
+
raise InvalidInputError(400, "invalid_query_parameters", f"Invalid query parameters: {', '.join(invalid_params)}")
|
|
89
86
|
|
|
90
87
|
def get_selections_as_immutable(self, params: Mapping, uncached_keys: set[str]) -> tuple[tuple[str, Any], ...]:
|
|
91
88
|
"""Convert selections into a cachable tuple of pairs"""
|
|
@@ -110,14 +107,6 @@ class RouteBase:
|
|
|
110
107
|
cache[cache_key] = result
|
|
111
108
|
return result
|
|
112
109
|
|
|
113
|
-
def _get_request_id(self, request: Request) -> str:
|
|
114
|
-
"""Get request ID from headers"""
|
|
115
|
-
return request.headers.get("x-request-id", "")
|
|
116
|
-
|
|
117
|
-
def log_activity_time(self, activity: str, start_time: float, request: Request) -> None:
|
|
118
|
-
"""Log activity time"""
|
|
119
|
-
self.logger.log_activity_time(activity, start_time, request_id=self._get_request_id(request))
|
|
120
|
-
|
|
121
110
|
def get_name_from_path_section(self, request: Request, section: int) -> str:
|
|
122
111
|
"""Extract name from request path section"""
|
|
123
112
|
url_path: str = request.scope['route'].path
|
|
@@ -132,23 +121,51 @@ class RouteBase:
|
|
|
132
121
|
except ValueError:
|
|
133
122
|
raise ConfigurationError(f"Value for environment variable {c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES} is not an integer, got: {expiry_mins}")
|
|
134
123
|
return expiry_mins
|
|
135
|
-
|
|
136
|
-
def
|
|
124
|
+
|
|
125
|
+
def get_headers_from_tool_ctx(self, tool_ctx: Context) -> dict[str, str]:
|
|
137
126
|
request = tool_ctx.request_context.request
|
|
138
127
|
assert request is not None and hasattr(request, "headers")
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
return dict(request.headers)
|
|
129
|
+
|
|
130
|
+
def get_configurables_from_headers(self, headers: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
|
|
131
|
+
"""Extract configurables from request headers with prefix 'x-config-'."""
|
|
132
|
+
prefix = "x-config-"
|
|
133
|
+
cfg_pairs: list[tuple[str, str]] = []
|
|
134
|
+
seen_configurables: dict[str, str] = {} # normalized_name -> header_name
|
|
142
135
|
|
|
136
|
+
for key, value in headers.items():
|
|
137
|
+
key_lower = str(key).lower()
|
|
138
|
+
if key_lower.startswith(prefix):
|
|
139
|
+
cfg_name_raw = key_lower[len(prefix):]
|
|
140
|
+
cfg_name_normalized = u.normalize_name(cfg_name_raw) # Convert to underscore convention
|
|
141
|
+
|
|
142
|
+
# Check if we've already seen this configurable (with different header format)
|
|
143
|
+
if cfg_name_normalized in seen_configurables:
|
|
144
|
+
existing_header = seen_configurables[cfg_name_normalized]
|
|
145
|
+
raise InvalidInputError(
|
|
146
|
+
400, "duplicate_configurable_header",
|
|
147
|
+
f"Only one header format is allowed for configurable '{cfg_name_normalized}'. "
|
|
148
|
+
f"Both '{existing_header}' and '{key_lower}' were provided."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
seen_configurables[cfg_name_normalized] = key_lower
|
|
152
|
+
cfg_pairs.append((cfg_name_normalized, str(value)))
|
|
153
|
+
|
|
154
|
+
configurables = [k for k, _ in cfg_pairs]
|
|
155
|
+
self.logger.info(f"Configurables specified: {configurables}", data={"configurables_specified": configurables})
|
|
156
|
+
return tuple(cfg_pairs)
|
|
157
|
+
|
|
158
|
+
def get_user_from_tool_headers(self, headers: dict[str, str]):
|
|
159
|
+
authorization_header = headers.get('Authorization')
|
|
143
160
|
if authorization_header:
|
|
144
|
-
# Split the header into 'Bearer <token>'
|
|
145
161
|
parts = authorization_header.split()
|
|
146
|
-
|
|
147
162
|
if len(parts) == 2 and parts[0] == 'Bearer':
|
|
148
163
|
access_token = parts[1]
|
|
149
164
|
user = self.authenticator.get_user_from_token(access_token)
|
|
165
|
+
if user is None:
|
|
166
|
+
return self.project._guest_user
|
|
150
167
|
return user
|
|
151
168
|
else:
|
|
152
169
|
raise ValueError("Invalid Authorization header format")
|
|
153
170
|
else:
|
|
154
|
-
return
|
|
171
|
+
return self.project._guest_user
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Dashboard routes for parameters and results
|
|
3
3
|
"""
|
|
4
|
-
from typing import Callable, Any
|
|
4
|
+
from typing import Callable, Coroutine, Any
|
|
5
5
|
from fastapi import FastAPI, Depends, Request, Response
|
|
6
6
|
from fastapi.responses import JSONResponse, HTMLResponse
|
|
7
7
|
from fastapi.security import HTTPBearer
|
|
@@ -14,7 +14,7 @@ from .._schemas import response_models as rm
|
|
|
14
14
|
from .._exceptions import ConfigurationError
|
|
15
15
|
from .._dashboards import Dashboard
|
|
16
16
|
from .._schemas.query_param_models import get_query_models_for_parameters, get_query_models_for_dashboard
|
|
17
|
-
from ..
|
|
17
|
+
from .._schemas.auth_models import AbstractUser
|
|
18
18
|
from .base import RouteBase
|
|
19
19
|
|
|
20
20
|
|
|
@@ -30,26 +30,30 @@ class DashboardRoutes(RouteBase):
|
|
|
30
30
|
self.dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
|
|
31
31
|
|
|
32
32
|
async def _get_dashboard_results_helper(
|
|
33
|
-
self, dashboard: str, user:
|
|
33
|
+
self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
|
|
34
34
|
) -> Dashboard:
|
|
35
35
|
"""Helper to get dashboard results"""
|
|
36
|
-
|
|
36
|
+
cfg_filtered = {k: v for k, v in dict(configurables).items() if k in self.manifest_cfg.configurables}
|
|
37
|
+
return await self.project.dashboard(dashboard, user=user, selections=dict(selections), configurables=cfg_filtered)
|
|
37
38
|
|
|
38
39
|
async def _get_dashboard_results_cachable(
|
|
39
|
-
self, dashboard: str, user:
|
|
40
|
+
self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
|
|
40
41
|
) -> Dashboard:
|
|
41
42
|
"""Cachable version of dashboard results helper"""
|
|
42
|
-
return await self.do_cachable_action(self.dashboard_results_cache, self._get_dashboard_results_helper, dashboard, user, selections)
|
|
43
|
+
return await self.do_cachable_action(self.dashboard_results_cache, self._get_dashboard_results_helper, dashboard, user, selections, configurables)
|
|
43
44
|
|
|
44
45
|
async def _get_dashboard_results_definition(
|
|
45
|
-
self, dashboard_name: str, user:
|
|
46
|
+
self, dashboard_name: str, user: AbstractUser, all_request_params: dict, params: dict, headers: dict[str, str]
|
|
46
47
|
) -> Response:
|
|
47
48
|
"""Get dashboard results definition"""
|
|
48
|
-
self._validate_request_params(all_request_params, params)
|
|
49
|
+
self._validate_request_params(all_request_params, params, headers)
|
|
49
50
|
|
|
50
51
|
get_dashboard_function = self._get_dashboard_results_helper if self.no_cache else self._get_dashboard_results_cachable
|
|
51
52
|
selections = self.get_selections_as_immutable(params, uncached_keys={"x_verify_params"})
|
|
52
|
-
|
|
53
|
+
|
|
54
|
+
user_has_elevated_privileges = u.user_has_elevated_privileges(user.access_level, self.project._elevated_access_level)
|
|
55
|
+
configurables = self.get_configurables_from_headers(headers) if user_has_elevated_privileges else tuple()
|
|
56
|
+
dashboard_obj = await get_dashboard_function(dashboard_name, user, selections, configurables)
|
|
53
57
|
|
|
54
58
|
if dashboard_obj._format == c.PNG:
|
|
55
59
|
assert isinstance(dashboard_obj._content, bytes)
|
|
@@ -61,7 +65,7 @@ class DashboardRoutes(RouteBase):
|
|
|
61
65
|
return result
|
|
62
66
|
|
|
63
67
|
def setup_routes(
|
|
64
|
-
self, app: FastAPI, project_metadata_path: str, param_fields: dict, get_parameters_definition: Callable
|
|
68
|
+
self, app: FastAPI, project_metadata_path: str, param_fields: dict, get_parameters_definition: Callable[..., Coroutine[Any, Any, rm.ParametersModel]]
|
|
65
69
|
) -> None:
|
|
66
70
|
"""Setup dashboard routes"""
|
|
67
71
|
|
|
@@ -81,9 +85,9 @@ class DashboardRoutes(RouteBase):
|
|
|
81
85
|
|
|
82
86
|
# Dashboard parameters and results APIs
|
|
83
87
|
for dashboard_name, dashboard in self.project._dashboards.items():
|
|
84
|
-
|
|
85
|
-
curr_parameters_path = dashboard_parameters_path.format(dashboard=
|
|
86
|
-
curr_results_path = dashboard_results_path.format(dashboard=
|
|
88
|
+
dashboard_name_for_api = u.normalize_name_for_api(dashboard_name)
|
|
89
|
+
curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_name_for_api)
|
|
90
|
+
curr_results_path = dashboard_results_path.format(dashboard=dashboard_name_for_api)
|
|
87
91
|
|
|
88
92
|
validate_parameters_list(dashboard.config.parameters, "Dashboard", dashboard_name)
|
|
89
93
|
|
|
@@ -99,9 +103,11 @@ class DashboardRoutes(RouteBase):
|
|
|
99
103
|
parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
|
|
100
104
|
scope = self.project._dashboards[curr_dashboard_name].config.scope
|
|
101
105
|
result = await get_parameters_definition(
|
|
102
|
-
parameters_list, "dashboard", curr_dashboard_name, scope, user, dict(request.query_params), asdict(params)
|
|
106
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
|
|
107
|
+
)
|
|
108
|
+
self.logger.log_activity_time(
|
|
109
|
+
"GET REQUEST for PARAMETERS", start, additional_data={"dashboard_name": curr_dashboard_name}
|
|
103
110
|
)
|
|
104
|
-
self.log_activity_time("GET REQUEST for PARAMETERS", start, request)
|
|
105
111
|
return result
|
|
106
112
|
|
|
107
113
|
@app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
|
|
@@ -114,9 +120,11 @@ class DashboardRoutes(RouteBase):
|
|
|
114
120
|
scope = self.project._dashboards[curr_dashboard_name].config.scope
|
|
115
121
|
payload: dict = await request.json()
|
|
116
122
|
result = await get_parameters_definition(
|
|
117
|
-
parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump()
|
|
123
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump(), headers=dict(request.headers)
|
|
124
|
+
)
|
|
125
|
+
self.logger.log_activity_time(
|
|
126
|
+
"POST REQUEST for PARAMETERS", start, additional_data={"dashboard_name": curr_dashboard_name}
|
|
118
127
|
)
|
|
119
|
-
self.log_activity_time("POST REQUEST for PARAMETERS", start, request)
|
|
120
128
|
return result
|
|
121
129
|
|
|
122
130
|
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
@@ -125,8 +133,12 @@ class DashboardRoutes(RouteBase):
|
|
|
125
133
|
) -> Response:
|
|
126
134
|
start = time.time()
|
|
127
135
|
curr_dashboard_name = self.get_name_from_path_section(request, -1)
|
|
128
|
-
result = await self._get_dashboard_results_definition(
|
|
129
|
-
|
|
136
|
+
result = await self._get_dashboard_results_definition(
|
|
137
|
+
curr_dashboard_name, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
|
|
138
|
+
)
|
|
139
|
+
self.logger.log_activity_time(
|
|
140
|
+
"GET REQUEST for DASHBOARD RESULTS", start, additional_data={"dashboard_name": curr_dashboard_name}
|
|
141
|
+
)
|
|
130
142
|
return result
|
|
131
143
|
|
|
132
144
|
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
@@ -136,7 +148,11 @@ class DashboardRoutes(RouteBase):
|
|
|
136
148
|
start = time.time()
|
|
137
149
|
curr_dashboard_name = self.get_name_from_path_section(request, -1)
|
|
138
150
|
payload: dict = await request.json()
|
|
139
|
-
result = await self._get_dashboard_results_definition(
|
|
140
|
-
|
|
151
|
+
result = await self._get_dashboard_results_definition(
|
|
152
|
+
curr_dashboard_name, user, payload, params.model_dump(), headers=dict(request.headers)
|
|
153
|
+
)
|
|
154
|
+
self.logger.log_activity_time(
|
|
155
|
+
"POST REQUEST for DASHBOARD RESULTS", start, additional_data={"dashboard_name": curr_dashboard_name}
|
|
156
|
+
)
|
|
141
157
|
return result
|
|
142
158
|
|