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.

Files changed (69) hide show
  1. squirrels/__init__.py +2 -0
  2. squirrels/_api_routes/auth.py +83 -74
  3. squirrels/_api_routes/base.py +58 -41
  4. squirrels/_api_routes/dashboards.py +37 -21
  5. squirrels/_api_routes/data_management.py +72 -27
  6. squirrels/_api_routes/datasets.py +107 -84
  7. squirrels/_api_routes/oauth2.py +11 -13
  8. squirrels/_api_routes/project.py +71 -33
  9. squirrels/_api_server.py +130 -63
  10. squirrels/_arguments/run_time_args.py +9 -9
  11. squirrels/_auth.py +117 -162
  12. squirrels/_command_line.py +68 -32
  13. squirrels/_compile_prompts.py +147 -0
  14. squirrels/_connection_set.py +11 -2
  15. squirrels/_constants.py +22 -8
  16. squirrels/_data_sources.py +38 -32
  17. squirrels/_dataset_types.py +2 -4
  18. squirrels/_initializer.py +1 -1
  19. squirrels/_logging.py +117 -0
  20. squirrels/_manifest.py +125 -58
  21. squirrels/_model_builder.py +10 -54
  22. squirrels/_models.py +224 -108
  23. squirrels/_package_data/base_project/.env +15 -4
  24. squirrels/_package_data/base_project/.env.example +14 -3
  25. squirrels/_package_data/base_project/connections.yml +4 -3
  26. squirrels/_package_data/base_project/dashboards/dashboard_example.py +2 -2
  27. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +4 -4
  28. squirrels/_package_data/base_project/duckdb_init.sql +1 -0
  29. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +7 -2
  30. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +16 -10
  31. squirrels/_package_data/base_project/models/federates/federate_example.py +22 -15
  32. squirrels/_package_data/base_project/models/federates/federate_example.sql +3 -7
  33. squirrels/_package_data/base_project/models/federates/federate_example.yml +1 -1
  34. squirrels/_package_data/base_project/models/sources.yml +5 -6
  35. squirrels/_package_data/base_project/parameters.yml +24 -38
  36. squirrels/_package_data/base_project/pyconfigs/connections.py +5 -1
  37. squirrels/_package_data/base_project/pyconfigs/context.py +23 -12
  38. squirrels/_package_data/base_project/pyconfigs/parameters.py +68 -33
  39. squirrels/_package_data/base_project/pyconfigs/user.py +11 -18
  40. squirrels/_package_data/base_project/seeds/seed_categories.yml +1 -1
  41. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +1 -1
  42. squirrels/_package_data/base_project/squirrels.yml.j2 +18 -28
  43. squirrels/_package_data/templates/squirrels_studio.html +20 -0
  44. squirrels/_parameter_configs.py +43 -22
  45. squirrels/_parameter_options.py +1 -1
  46. squirrels/_parameter_sets.py +8 -10
  47. squirrels/_project.py +351 -234
  48. squirrels/_request_context.py +33 -0
  49. squirrels/_schemas/auth_models.py +32 -9
  50. squirrels/_schemas/query_param_models.py +9 -1
  51. squirrels/_schemas/response_models.py +36 -10
  52. squirrels/_seeds.py +1 -1
  53. squirrels/_sources.py +23 -19
  54. squirrels/_utils.py +83 -35
  55. squirrels/_version.py +1 -1
  56. squirrels/arguments.py +5 -0
  57. squirrels/auth.py +4 -1
  58. squirrels/connections.py +2 -0
  59. squirrels/dashboards.py +3 -1
  60. squirrels/data_sources.py +6 -0
  61. squirrels/parameter_options.py +5 -0
  62. squirrels/parameters.py +5 -0
  63. squirrels/types.py +6 -1
  64. {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/METADATA +28 -13
  65. squirrels-0.5.1.dist-info/RECORD +98 -0
  66. squirrels-0.5.0b4.dist-info/RECORD +0 -94
  67. {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/WHEEL +0 -0
  68. {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/entry_points.txt +0 -0
  69. {squirrels-0.5.0b4.dist-info → squirrels-0.5.1.dist-info}/licenses/LICENSE +0 -0
squirrels/__init__.py CHANGED
@@ -17,3 +17,5 @@ from .dashboards import *
17
17
  from .types import *
18
18
 
19
19
  from ._project import SquirrelsProject
20
+
21
+ __all__ = ["SquirrelsProject"]
@@ -1,17 +1,16 @@
1
1
  """
2
2
  Authentication and user management routes
3
3
  """
4
- from typing import Annotated, Callable
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 .._auth import BaseUser
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
- auth_router = APIRouter(prefix="/api/auth")
28
- user_management_router = APIRouter(prefix="/api/auth/user-management")
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.UserModel):
35
- is_admin: bool
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: UserInfoModel | None = Depends(self.get_current_user)) -> UserInfoModel:
58
- if user is None:
59
- raise InvalidInputError(401, "Invalid authorization token", "Invalid authorization token")
60
- return user
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: BaseUser, redirect_url: str | None, *,
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: UserInfoModel | None = Depends(self.get_current_user)
89
+ request: Request, redirect_url: str | None = None, user: RegisteredUser | GuestUser = Depends(self.get_current_user)
87
90
  ):
88
- if user is None:
89
- raise InvalidInputError(401, "Invalid authorization token", "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
- """Get OAuth login URL for the provider"""
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 = '/change-password'
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: UserInfoModel | None = Depends(self.get_current_user)) -> None:
176
- if user is None:
177
- raise InvalidInputError(401, "Invalid authorization token", "Invalid authorization token")
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: UserInfoModel | None = Depends(self.get_current_user)) -> rm.ApiKeyResponse:
192
- if user is None:
193
- raise InvalidInputError(401, "Invalid authorization token", "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: UserInfoModel | None = Depends(self.get_current_user)):
200
- if user is None:
201
- raise InvalidInputError(401, "Invalid authorization token", "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: UserInfoModel | None = Depends(self.get_current_user)) -> Response:
210
- if user is None:
211
- raise InvalidInputError(401, "Invalid authorization token", "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
- user_fields_path = '/user-fields'
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
- @user_management_router.get(user_fields_path, description="Get details of the user fields", tags=["User Management"])
219
- async def get_user_fields():
220
- return self.authenticator.user_fields
221
-
222
- add_user_path = '/users'
223
-
224
- @user_management_router.post(add_user_path, description="Add a new user by providing details for username, password, and user fields", tags=["User Management"])
225
- async def add_user(
226
- new_user: AddUserModel, user: UserInfoModel | None = Depends(self.get_current_user)
227
- ) -> None:
228
- if user is None or not user.is_admin:
229
- raise InvalidInputError(403, "Forbidden to add user", "Authorized user is forbidden to add new users")
230
- self.authenticator.add_user(new_user.username, new_user.model_dump(mode='json', exclude={"username"}))
231
-
232
- update_user_path = '/users/{username}'
233
-
234
- @user_management_router.put(update_user_path, description="Update the user of the given username given the new user details", tags=["User Management"])
235
- async def update_user(
236
- username: str, updated_user: UpdateUserModel, user: UserInfoModel | None = Depends(self.get_current_user)
237
- ) -> None:
238
- if user is None or not user.is_admin:
239
- raise InvalidInputError(403, "Forbidden to update user", "Authorized user is forbidden to update users")
240
- self.authenticator.add_user(username, updated_user.model_dump(mode='json'), update_user=True)
241
-
242
- list_users_path = '/users'
243
-
244
- @user_management_router.get(list_users_path, tags=["User Management"])
245
- async def list_all_users():
246
- return self.authenticator.get_all_users()
247
-
248
- delete_user_path = '/users/{username}'
249
-
250
- @user_management_router.delete(delete_user_path, tags=["User Management"], responses={
251
- 204: { "description": "User deleted successfully" }
252
- })
253
- async def delete_user(username: str, user: UserInfoModel | None = Depends(self.get_current_user)) -> Response:
254
- if user is None or not user.is_admin:
255
- raise InvalidInputError(403, "Forbidden to delete user", "Authorized user is forbidden to delete users")
256
- if username == user.username:
257
- raise InvalidInputError(403, "Cannot delete your own user", "Cannot delete your own user")
258
- self.authenticator.delete_user(username)
259
- return Response(status_code=204)
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)
@@ -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
- ) -> UserInfoModel | None:
50
+ ) -> GuestUser | RegisteredUser:
66
51
  token = auth.credentials if auth and auth.scheme == "Bearer" else None
67
- final_token = token if token else get_token_from_session(request)
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
- username = "" if user is None else user.username
70
- response.headers["Applied-Username"] = username
71
- return UserInfoModel(**user.model_dump(mode='json')) if user else None
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
- if params.get("x_verify_params", False):
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, "Invalid query parameters", f"Invalid query parameters: {', '.join(invalid_params)}")
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 get_user_from_tool_ctx(self, tool_ctx: Context):
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
- headers: dict[str, str] = request.headers
140
- # Check if 'Authorization' header is present
141
- authorization_header = headers.get('Authorization')
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 None
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 .._auth import BaseUser
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: BaseUser | None, selections: tuple[tuple[str, Any], ...]
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
- return await self.project.dashboard(dashboard, selections=dict(selections), user=user)
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: BaseUser | None, selections: tuple[tuple[str, Any], ...]
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: BaseUser | None, all_request_params: dict, params: dict
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
- dashboard_obj = await get_dashboard_function(dashboard_name, user, selections)
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
- dashboard_normalized = u.normalize_name_for_api(dashboard_name)
85
- curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_normalized)
86
- curr_results_path = dashboard_results_path.format(dashboard=dashboard_normalized)
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(curr_dashboard_name, user, dict(request.query_params), asdict(params))
129
- self.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request)
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(curr_dashboard_name, user, payload, params.model_dump())
140
- self.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request)
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