squirrels 0.4.1__py3-none-any.whl → 0.5.0__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.
- dateutils/__init__.py +6 -0
- dateutils/_enums.py +25 -0
- squirrels/dateutils.py → dateutils/_implementation.py +58 -111
- dateutils/types.py +6 -0
- squirrels/__init__.py +13 -11
- squirrels/_api_routes/__init__.py +5 -0
- squirrels/_api_routes/auth.py +271 -0
- squirrels/_api_routes/base.py +165 -0
- squirrels/_api_routes/dashboards.py +150 -0
- squirrels/_api_routes/data_management.py +145 -0
- squirrels/_api_routes/datasets.py +257 -0
- squirrels/_api_routes/oauth2.py +298 -0
- squirrels/_api_routes/project.py +252 -0
- squirrels/_api_server.py +256 -450
- squirrels/_arguments/__init__.py +0 -0
- squirrels/_arguments/init_time_args.py +108 -0
- squirrels/_arguments/run_time_args.py +147 -0
- squirrels/_auth.py +960 -0
- squirrels/_command_line.py +126 -45
- squirrels/_compile_prompts.py +147 -0
- squirrels/_connection_set.py +48 -26
- squirrels/_constants.py +68 -38
- squirrels/_dashboards.py +160 -0
- squirrels/_data_sources.py +570 -0
- squirrels/_dataset_types.py +84 -0
- squirrels/_exceptions.py +29 -0
- squirrels/_initializer.py +177 -80
- squirrels/_logging.py +115 -0
- squirrels/_manifest.py +208 -79
- squirrels/_model_builder.py +69 -0
- squirrels/_model_configs.py +74 -0
- squirrels/_model_queries.py +52 -0
- squirrels/_models.py +926 -367
- squirrels/_package_data/base_project/.env +42 -0
- squirrels/_package_data/base_project/.env.example +42 -0
- squirrels/_package_data/base_project/assets/expenses.db +0 -0
- squirrels/_package_data/base_project/connections.yml +16 -0
- squirrels/_package_data/base_project/dashboards/dashboard_example.py +34 -0
- squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
- squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +5 -2
- squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +3 -3
- squirrels/{package_data → _package_data}/base_project/docker/compose.yml +1 -1
- squirrels/_package_data/base_project/duckdb_init.sql +10 -0
- squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +3 -2
- squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
- squirrels/_package_data/base_project/models/builds/build_example.py +26 -0
- squirrels/_package_data/base_project/models/builds/build_example.sql +16 -0
- squirrels/_package_data/base_project/models/builds/build_example.yml +57 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +12 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +26 -0
- squirrels/_package_data/base_project/models/federates/federate_example.py +37 -0
- squirrels/_package_data/base_project/models/federates/federate_example.sql +19 -0
- squirrels/_package_data/base_project/models/federates/federate_example.yml +65 -0
- squirrels/_package_data/base_project/models/sources.yml +38 -0
- squirrels/{package_data → _package_data}/base_project/parameters.yml +56 -40
- squirrels/_package_data/base_project/pyconfigs/connections.py +14 -0
- squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +21 -40
- squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
- squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
- squirrels/_package_data/base_project/seeds/seed_categories.yml +15 -0
- squirrels/_package_data/base_project/seeds/seed_subcategories.csv +15 -0
- squirrels/_package_data/base_project/seeds/seed_subcategories.yml +21 -0
- squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
- squirrels/_package_data/templates/dataset_results.html +112 -0
- squirrels/_package_data/templates/oauth_login.html +271 -0
- squirrels/_package_data/templates/squirrels_studio.html +20 -0
- squirrels/_package_loader.py +8 -4
- squirrels/_parameter_configs.py +104 -103
- squirrels/_parameter_options.py +348 -0
- squirrels/_parameter_sets.py +57 -47
- squirrels/_parameters.py +1664 -0
- squirrels/_project.py +721 -0
- squirrels/_py_module.py +7 -5
- squirrels/_schemas/__init__.py +0 -0
- squirrels/_schemas/auth_models.py +167 -0
- squirrels/_schemas/query_param_models.py +75 -0
- squirrels/{_api_response_models.py → _schemas/response_models.py} +126 -47
- squirrels/_seeds.py +35 -16
- squirrels/_sources.py +110 -0
- squirrels/_utils.py +248 -73
- squirrels/_version.py +1 -1
- squirrels/arguments.py +7 -0
- squirrels/auth.py +4 -0
- squirrels/connections.py +3 -0
- squirrels/dashboards.py +2 -81
- squirrels/data_sources.py +14 -631
- squirrels/parameter_options.py +13 -348
- squirrels/parameters.py +14 -1266
- squirrels/types.py +16 -0
- squirrels-0.5.0.dist-info/METADATA +113 -0
- squirrels-0.5.0.dist-info/RECORD +97 -0
- {squirrels-0.4.1.dist-info → squirrels-0.5.0.dist-info}/WHEEL +1 -1
- squirrels-0.5.0.dist-info/entry_points.txt +3 -0
- {squirrels-0.4.1.dist-info → squirrels-0.5.0.dist-info/licenses}/LICENSE +1 -1
- squirrels/_authenticator.py +0 -85
- squirrels/_dashboards_io.py +0 -61
- squirrels/_environcfg.py +0 -84
- squirrels/arguments/init_time_args.py +0 -40
- squirrels/arguments/run_time_args.py +0 -208
- squirrels/package_data/assets/favicon.ico +0 -0
- squirrels/package_data/assets/index.css +0 -1
- squirrels/package_data/assets/index.js +0 -58
- squirrels/package_data/base_project/assets/expenses.db +0 -0
- squirrels/package_data/base_project/connections.yml +0 -7
- squirrels/package_data/base_project/dashboards/dashboard_example.py +0 -32
- squirrels/package_data/base_project/dashboards.yml +0 -10
- squirrels/package_data/base_project/env.yml +0 -29
- squirrels/package_data/base_project/models/dbviews/dbview_example.py +0 -47
- squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -22
- squirrels/package_data/base_project/models/federates/federate_example.py +0 -21
- squirrels/package_data/base_project/models/federates/federate_example.sql +0 -3
- squirrels/package_data/base_project/pyconfigs/auth.py +0 -45
- squirrels/package_data/base_project/pyconfigs/connections.py +0 -19
- squirrels/package_data/base_project/pyconfigs/parameters.py +0 -95
- squirrels/package_data/base_project/seeds/seed_subcategories.csv +0 -15
- squirrels/package_data/base_project/squirrels.yml.j2 +0 -94
- squirrels/package_data/templates/index.html +0 -18
- squirrels/project.py +0 -378
- squirrels/user_base.py +0 -55
- squirrels-0.4.1.dist-info/METADATA +0 -117
- squirrels-0.4.1.dist-info/RECORD +0 -60
- squirrels-0.4.1.dist-info/entry_points.txt +0 -4
- /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
- /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
- /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication and user management routes
|
|
3
|
+
"""
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
from fastapi import FastAPI, Depends, Request, Response, status, Form, APIRouter
|
|
6
|
+
from fastapi.responses import RedirectResponse
|
|
7
|
+
from fastapi.security import HTTPBearer
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from authlib.integrations.starlette_client import OAuth
|
|
10
|
+
|
|
11
|
+
from .._schemas import response_models as rm
|
|
12
|
+
from .._exceptions import InvalidInputError
|
|
13
|
+
from .._schemas.auth_models import AbstractUser, RegisteredUser, GuestUser
|
|
14
|
+
from .base import RouteBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthRoutes(RouteBase):
|
|
18
|
+
"""Authentication and user management routes"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, get_bearer_token: HTTPBearer, project, no_cache: bool = False):
|
|
21
|
+
super().__init__(get_bearer_token, project, no_cache)
|
|
22
|
+
|
|
23
|
+
def setup_routes(self, app: FastAPI, squirrels_version_path: str) -> None:
|
|
24
|
+
"""Setup all authentication routes"""
|
|
25
|
+
|
|
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
|
+
|
|
30
|
+
# Get expiry configuration
|
|
31
|
+
expiry_mins = self._get_access_token_expiry_minutes()
|
|
32
|
+
|
|
33
|
+
# Create user models
|
|
34
|
+
class UpdateUserModel(self.authenticator.CustomUserFields):
|
|
35
|
+
access_level: Literal["admin", "member"]
|
|
36
|
+
|
|
37
|
+
class UserInfoModel(UpdateUserModel):
|
|
38
|
+
username: str
|
|
39
|
+
|
|
40
|
+
class AddUserModel(UserInfoModel):
|
|
41
|
+
password: str
|
|
42
|
+
|
|
43
|
+
# Setup OAuth2 login providers
|
|
44
|
+
oauth = OAuth()
|
|
45
|
+
|
|
46
|
+
for provider in self.authenticator.auth_providers:
|
|
47
|
+
oauth.register(
|
|
48
|
+
name=provider.name,
|
|
49
|
+
server_metadata_url=provider.provider_configs.server_metadata_url,
|
|
50
|
+
client_id=provider.provider_configs.client_id,
|
|
51
|
+
client_secret=provider.provider_configs.client_secret,
|
|
52
|
+
client_kwargs=provider.provider_configs.client_kwargs
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# User info endpoint
|
|
56
|
+
@auth_router.get("/userinfo", description="Get the authenticated user's fields", tags=["Authentication"])
|
|
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)
|
|
62
|
+
|
|
63
|
+
# Login helper
|
|
64
|
+
def login_helper(
|
|
65
|
+
request: Request, user: RegisteredUser, redirect_url: str | None, *,
|
|
66
|
+
redirect_status_code: int = status.HTTP_307_TEMPORARY_REDIRECT
|
|
67
|
+
):
|
|
68
|
+
access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
|
|
69
|
+
request.session["access_token"] = access_token
|
|
70
|
+
request.session["access_token_expiry"] = expiry.timestamp()
|
|
71
|
+
return RedirectResponse(url=redirect_url, status_code=redirect_status_code) if redirect_url else user
|
|
72
|
+
|
|
73
|
+
# Login endpoints
|
|
74
|
+
@auth_router.post("/login", tags=["Authentication"], description="Authenticate with username and password. Returns user information if no redirect_url is provided, otherwise redirects to the specified URL.", responses={
|
|
75
|
+
200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
|
|
76
|
+
302: {"description": "Redirect if redirect URL parameter is specified"},
|
|
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'")
|
|
81
|
+
user = self.authenticator.get_user(username, password)
|
|
82
|
+
return login_helper(request, user, redirect_url, redirect_status_code=status.HTTP_302_FOUND)
|
|
83
|
+
|
|
84
|
+
@auth_router.get("/login", tags=["Authentication"], description="Authenticate with an existing API key or session token. Returns user information if no redirect_url is provided, otherwise redirects to the specified URL.", responses={
|
|
85
|
+
200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
|
|
86
|
+
307: {"description": "Redirect if redirect URL parameter is specified"},
|
|
87
|
+
})
|
|
88
|
+
async def login_with_api_key(
|
|
89
|
+
request: Request, redirect_url: str | None = None, user: RegisteredUser | GuestUser = Depends(self.get_current_user)
|
|
90
|
+
):
|
|
91
|
+
if isinstance(user, GuestUser):
|
|
92
|
+
raise InvalidInputError(401, "invalid_authorization_token", "Invalid authorization token, no user info found")
|
|
93
|
+
return login_helper(request, user, redirect_url)
|
|
94
|
+
|
|
95
|
+
# Provider authentication endpoints
|
|
96
|
+
providers_path = '/providers'
|
|
97
|
+
provider_login_path = '/providers/{provider_name}/login'
|
|
98
|
+
provider_callback_path = '/providers/{provider_name}/callback'
|
|
99
|
+
|
|
100
|
+
@auth_router.get(providers_path, tags=["Authentication"])
|
|
101
|
+
async def get_providers(request: Request) -> list[rm.ProviderResponse]:
|
|
102
|
+
"""Get list of available authentication providers"""
|
|
103
|
+
return [
|
|
104
|
+
rm.ProviderResponse(
|
|
105
|
+
name=provider.name,
|
|
106
|
+
label=provider.label,
|
|
107
|
+
icon=provider.icon,
|
|
108
|
+
login_url=str(request.url_for('provider_login', provider_name=provider.name))
|
|
109
|
+
)
|
|
110
|
+
for provider in self.authenticator.auth_providers
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
@auth_router.get(provider_login_path, tags=["Authentication"])
|
|
114
|
+
async def provider_login(request: Request, provider_name: str, redirect_url: str | None = None) -> RedirectResponse:
|
|
115
|
+
"""Redirect to the login URL for the OAuth provider"""
|
|
116
|
+
client = oauth.create_client(provider_name)
|
|
117
|
+
if client is None:
|
|
118
|
+
raise InvalidInputError(status_code=404, error="provider_not_found", error_description=f"Provider {provider_name} not found or configured.")
|
|
119
|
+
|
|
120
|
+
callback_uri = str(request.url_for('provider_callback', provider_name=provider_name))
|
|
121
|
+
request.session["redirect_url"] = redirect_url
|
|
122
|
+
|
|
123
|
+
return await client.authorize_redirect(request, callback_uri)
|
|
124
|
+
|
|
125
|
+
@auth_router.get(provider_callback_path, tags=["Authentication"], responses={
|
|
126
|
+
200: {"model": UserInfoModel, "description": "Login successful, returns user information"},
|
|
127
|
+
302: {"description": "Redirect if redirect_url is in session"},
|
|
128
|
+
})
|
|
129
|
+
async def provider_callback(request: Request, provider_name: str):
|
|
130
|
+
"""Handle OAuth callback from provider"""
|
|
131
|
+
client = oauth.create_client(provider_name)
|
|
132
|
+
if client is None:
|
|
133
|
+
raise InvalidInputError(status_code=404, error="provider_not_found", error_description=f"Provider {provider_name} not found or configured.")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
token = await client.authorize_access_token(request)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise InvalidInputError(status_code=400, error="provider_authorization_failed", error_description=f"Could not authorize with provider for access token: {str(e)}")
|
|
139
|
+
|
|
140
|
+
user_info: dict = {}
|
|
141
|
+
if token:
|
|
142
|
+
if 'userinfo' in token:
|
|
143
|
+
user_info = token['userinfo']
|
|
144
|
+
elif 'id_token' in token and isinstance(token['id_token'], dict) and 'sub' in token['id_token']:
|
|
145
|
+
user_info = token['id_token']
|
|
146
|
+
else:
|
|
147
|
+
raise InvalidInputError(status_code=400, error="invalid_provider_user_info", error_description=f"User information not found in token for {provider_name}")
|
|
148
|
+
|
|
149
|
+
user = self.authenticator.create_or_get_user_from_provider(provider_name, user_info)
|
|
150
|
+
access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
|
|
151
|
+
request.session["access_token"] = access_token
|
|
152
|
+
request.session["access_token_expiry"] = expiry.timestamp()
|
|
153
|
+
|
|
154
|
+
redirect_url = request.session.pop("redirect_url", None)
|
|
155
|
+
return RedirectResponse(url=redirect_url) if redirect_url else user
|
|
156
|
+
|
|
157
|
+
# Logout endpoint
|
|
158
|
+
logout_path = '/logout'
|
|
159
|
+
|
|
160
|
+
@auth_router.get(logout_path, tags=["Authentication"], responses={
|
|
161
|
+
200: {"description": "Logout successful"},
|
|
162
|
+
302: {"description": "Redirect if redirect URL parameter is specified"},
|
|
163
|
+
})
|
|
164
|
+
async def logout(request: Request, redirect_url: str | None = None):
|
|
165
|
+
"""Logout the current user, and redirect to the specified URL if provided"""
|
|
166
|
+
request.session.pop("access_token", None)
|
|
167
|
+
request.session.pop("access_token_expiry", None)
|
|
168
|
+
if redirect_url:
|
|
169
|
+
return RedirectResponse(url=redirect_url)
|
|
170
|
+
|
|
171
|
+
# Change password endpoint
|
|
172
|
+
change_password_path = '/password'
|
|
173
|
+
|
|
174
|
+
class ChangePasswordRequest(BaseModel):
|
|
175
|
+
old_password: str
|
|
176
|
+
new_password: str
|
|
177
|
+
|
|
178
|
+
@auth_router.put(change_password_path, description="Change the password for the current user", tags=["Authentication"])
|
|
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")
|
|
182
|
+
self.authenticator.change_password(user.username, request.old_password, request.new_password)
|
|
183
|
+
|
|
184
|
+
# API Key endpoints
|
|
185
|
+
api_key_path = '/api-key'
|
|
186
|
+
|
|
187
|
+
class ApiKeyRequestBody(BaseModel):
|
|
188
|
+
title: str = Field(description=f"The title of the API key")
|
|
189
|
+
expiry_minutes: int | None = Field(
|
|
190
|
+
default=None,
|
|
191
|
+
description=f"The number of minutes the API key is valid for (or valid indefinitely if not provided)."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@auth_router.post(api_key_path, description="Create a new API key for the user", tags=["Authentication"])
|
|
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")
|
|
198
|
+
|
|
199
|
+
api_key, _ = self.authenticator.create_access_token(user, expiry_minutes=body.expiry_minutes, title=body.title)
|
|
200
|
+
return rm.ApiKeyResponse(api_key=api_key)
|
|
201
|
+
|
|
202
|
+
@auth_router.get(api_key_path, description="Get all API keys with title for the current user", tags=["Authentication"])
|
|
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")
|
|
206
|
+
return self.authenticator.get_all_api_keys(user.username)
|
|
207
|
+
|
|
208
|
+
revoke_api_key_path = '/api-key/{api_key_id}'
|
|
209
|
+
|
|
210
|
+
@auth_router.delete(revoke_api_key_path, description="Revoke an API key", tags=["Authentication"], responses={
|
|
211
|
+
204: { "description": "API key revoked successfully" }
|
|
212
|
+
})
|
|
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")
|
|
216
|
+
self.authenticator.revoke_api_key(user.username, api_key_id)
|
|
217
|
+
return Response(status_code=204)
|
|
218
|
+
|
|
219
|
+
# User management endpoints (disabled if external auth only)
|
|
220
|
+
if self.manifest_cfg.authentication.type.value == "managed":
|
|
221
|
+
user_fields_path = '/user-fields'
|
|
222
|
+
|
|
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)
|
|
269
|
+
|
|
270
|
+
app.include_router(auth_router)
|
|
271
|
+
app.include_router(user_management_router)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base utilities and dependencies for API routes
|
|
3
|
+
"""
|
|
4
|
+
from typing import Any, Mapping, TypeVar, Callable, Coroutine, Literal
|
|
5
|
+
from fastapi import Request, Response, Depends
|
|
6
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
|
+
from fastapi.templating import Jinja2Templates
|
|
8
|
+
from cachetools import TTLCache
|
|
9
|
+
from pydantic import BaseModel, create_model, Field
|
|
10
|
+
from mcp.server.fastmcp import Context
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from .. import _utils as u, _constants as c
|
|
15
|
+
from .._exceptions import InvalidInputError, ConfigurationError
|
|
16
|
+
from .._project import SquirrelsProject
|
|
17
|
+
from .._schemas.auth_models import GuestUser, RegisteredUser
|
|
18
|
+
|
|
19
|
+
T = TypeVar('T')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RouteBase:
|
|
23
|
+
"""Base class for route modules providing common functionality"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, get_bearer_token: HTTPBearer, project: SquirrelsProject, no_cache: bool = False):
|
|
26
|
+
self.project = project
|
|
27
|
+
self.no_cache = no_cache
|
|
28
|
+
self.logger = project._logger
|
|
29
|
+
self.env_vars = project._env_vars
|
|
30
|
+
self.manifest_cfg = project._manifest_cfg
|
|
31
|
+
self.authenticator = project._auth
|
|
32
|
+
self.param_cfg_set = project._param_cfg_set
|
|
33
|
+
|
|
34
|
+
# Setup templates
|
|
35
|
+
template_dir = Path(__file__).parent.parent / "_package_data" / "templates"
|
|
36
|
+
self.templates = Jinja2Templates(directory=str(template_dir))
|
|
37
|
+
|
|
38
|
+
# Authorization dependency for current user
|
|
39
|
+
def get_token_from_session(request: Request) -> str | None:
|
|
40
|
+
expiry = request.session.get("access_token_expiry")
|
|
41
|
+
datetime_now = datetime.now(timezone.utc).timestamp()
|
|
42
|
+
if expiry and expiry > datetime_now:
|
|
43
|
+
return request.session.get("access_token")
|
|
44
|
+
else:
|
|
45
|
+
request.session.pop("access_token", None)
|
|
46
|
+
request.session.pop("access_token_expiry", None)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def get_current_user(
|
|
50
|
+
request: Request, response: Response, auth: HTTPAuthorizationCredentials = Depends(get_bearer_token)
|
|
51
|
+
) -> GuestUser | RegisteredUser:
|
|
52
|
+
token = auth.credentials if auth and auth.scheme == "Bearer" else None
|
|
53
|
+
access_token = token if token else get_token_from_session(request)
|
|
54
|
+
api_key = request.headers.get("x-api-key")
|
|
55
|
+
final_token = api_key if api_key else access_token
|
|
56
|
+
|
|
57
|
+
user = self.authenticator.get_user_from_token(final_token)
|
|
58
|
+
if user is None:
|
|
59
|
+
user = self.project._guest_user
|
|
60
|
+
|
|
61
|
+
response.headers["Applied-Username"] = user.username
|
|
62
|
+
return user
|
|
63
|
+
|
|
64
|
+
self.get_current_user = get_current_user
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _parameters_description(self) -> str:
|
|
68
|
+
"""Get the standard parameters description"""
|
|
69
|
+
return "Selections of one parameter may cascade the available options in another parameter. " \
|
|
70
|
+
"For example, if the dataset has parameters for 'country' and 'city', available options for 'city' would " \
|
|
71
|
+
"depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
|
|
72
|
+
"selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
|
|
73
|
+
|
|
74
|
+
def _validate_request_params(self, all_request_params: Mapping, params: Mapping, headers: Mapping[str, str]) -> None:
|
|
75
|
+
"""Validate request parameters
|
|
76
|
+
|
|
77
|
+
When header 'x-verify-params' is set to a truthy value, ensure that all provided
|
|
78
|
+
query/body parameters are part of the parsed params model. Falls back to legacy
|
|
79
|
+
query param 'x_verify_params' for backward compatibility.
|
|
80
|
+
"""
|
|
81
|
+
header_val = headers.get("x-verify-params")
|
|
82
|
+
verify_params = u.to_bool(header_val) or u.to_bool(params.get("x_verify_params", False))
|
|
83
|
+
if verify_params:
|
|
84
|
+
invalid_params = [param for param in all_request_params if param not in params]
|
|
85
|
+
if invalid_params:
|
|
86
|
+
raise InvalidInputError(400, "invalid_query_parameters", f"Invalid query parameters: {', '.join(invalid_params)}")
|
|
87
|
+
|
|
88
|
+
def get_selections_as_immutable(self, params: Mapping, uncached_keys: set[str]) -> tuple[tuple[str, Any], ...]:
|
|
89
|
+
"""Convert selections into a cachable tuple of pairs"""
|
|
90
|
+
selections = list()
|
|
91
|
+
for key, val in params.items():
|
|
92
|
+
if key in uncached_keys or val is None:
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(val, (list, tuple)):
|
|
95
|
+
if len(val) == 1: # for backward compatibility
|
|
96
|
+
val = val[0]
|
|
97
|
+
else:
|
|
98
|
+
val = tuple(val)
|
|
99
|
+
selections.append((u.normalize_name(key), val))
|
|
100
|
+
return tuple(selections)
|
|
101
|
+
|
|
102
|
+
async def do_cachable_action(self, cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
|
|
103
|
+
"""Execute a cachable action"""
|
|
104
|
+
cache_key = tuple(args)
|
|
105
|
+
result = cache.get(cache_key)
|
|
106
|
+
if result is None:
|
|
107
|
+
result = await action(*args)
|
|
108
|
+
cache[cache_key] = result
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
def _get_request_id(self, request: Request) -> str:
|
|
112
|
+
"""Get request ID from headers"""
|
|
113
|
+
return request.headers.get("x-request-id", "")
|
|
114
|
+
|
|
115
|
+
def log_activity_time(self, activity: str, start_time: float, request: Request) -> None:
|
|
116
|
+
"""Log activity time"""
|
|
117
|
+
self.logger.log_activity_time(activity, start_time, request_id=self._get_request_id(request))
|
|
118
|
+
|
|
119
|
+
def get_name_from_path_section(self, request: Request, section: int) -> str:
|
|
120
|
+
"""Extract name from request path section"""
|
|
121
|
+
url_path: str = request.scope['route'].path
|
|
122
|
+
name_raw = url_path.split('/')[section]
|
|
123
|
+
return u.normalize_name(name_raw)
|
|
124
|
+
|
|
125
|
+
def _get_access_token_expiry_minutes(self) -> int:
|
|
126
|
+
"""Get access token expiry minutes"""
|
|
127
|
+
expiry_mins = self.env_vars.get(c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES, 30)
|
|
128
|
+
try:
|
|
129
|
+
expiry_mins = int(expiry_mins)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise ConfigurationError(f"Value for environment variable {c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES} is not an integer, got: {expiry_mins}")
|
|
132
|
+
return expiry_mins
|
|
133
|
+
|
|
134
|
+
def get_headers_from_tool_ctx(self, tool_ctx: Context) -> dict[str, str]:
|
|
135
|
+
request = tool_ctx.request_context.request
|
|
136
|
+
assert request is not None and hasattr(request, "headers")
|
|
137
|
+
return dict(request.headers)
|
|
138
|
+
|
|
139
|
+
def get_configurables_from_headers(self, headers: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
|
|
140
|
+
"""Extract configurables from request headers with prefix 'x-config-'."""
|
|
141
|
+
prefix = "x-config-"
|
|
142
|
+
cfg_pairs: list[tuple[str, str]] = []
|
|
143
|
+
for key, value in headers.items():
|
|
144
|
+
key_lower = str(key).lower()
|
|
145
|
+
if key_lower.startswith(prefix):
|
|
146
|
+
cfg_name = key_lower[len(prefix):]
|
|
147
|
+
cfg_pairs.append((u.normalize_name(cfg_name), str(value)))
|
|
148
|
+
|
|
149
|
+
self.logger.info(f"Configurables specified: {[k for k, _ in cfg_pairs]}")
|
|
150
|
+
return tuple(cfg_pairs)
|
|
151
|
+
|
|
152
|
+
def get_user_from_tool_headers(self, headers: dict[str, str]):
|
|
153
|
+
authorization_header = headers.get('Authorization')
|
|
154
|
+
if authorization_header:
|
|
155
|
+
parts = authorization_header.split()
|
|
156
|
+
if len(parts) == 2 and parts[0] == 'Bearer':
|
|
157
|
+
access_token = parts[1]
|
|
158
|
+
user = self.authenticator.get_user_from_token(access_token)
|
|
159
|
+
if user is None:
|
|
160
|
+
return self.project._guest_user
|
|
161
|
+
return user
|
|
162
|
+
else:
|
|
163
|
+
raise ValueError("Invalid Authorization header format")
|
|
164
|
+
else:
|
|
165
|
+
return self.project._guest_user
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dashboard routes for parameters and results
|
|
3
|
+
"""
|
|
4
|
+
from typing import Callable, Coroutine, Any
|
|
5
|
+
from fastapi import FastAPI, Depends, Request, Response
|
|
6
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
7
|
+
from fastapi.security import HTTPBearer
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from cachetools import TTLCache
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .. import _constants as c, _utils as u
|
|
13
|
+
from .._schemas import response_models as rm
|
|
14
|
+
from .._exceptions import ConfigurationError
|
|
15
|
+
from .._dashboards import Dashboard
|
|
16
|
+
from .._schemas.query_param_models import get_query_models_for_parameters, get_query_models_for_dashboard
|
|
17
|
+
from .._schemas.auth_models import AbstractUser
|
|
18
|
+
from .base import RouteBase
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DashboardRoutes(RouteBase):
|
|
22
|
+
"""Dashboard parameter and result routes"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, get_bearer_token: HTTPBearer, project, no_cache: bool = False):
|
|
25
|
+
super().__init__(get_bearer_token, project, no_cache)
|
|
26
|
+
|
|
27
|
+
# Setup caches
|
|
28
|
+
dashboard_results_cache_size = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_SIZE, 128))
|
|
29
|
+
dashboard_results_cache_ttl = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_TTL_MINUTES, 60))
|
|
30
|
+
self.dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
|
|
31
|
+
|
|
32
|
+
async def _get_dashboard_results_helper(
|
|
33
|
+
self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
|
|
34
|
+
) -> Dashboard:
|
|
35
|
+
"""Helper to get dashboard results"""
|
|
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, selections=dict(selections), configurables=cfg_filtered)
|
|
38
|
+
|
|
39
|
+
async def _get_dashboard_results_cachable(
|
|
40
|
+
self, dashboard: str, user: AbstractUser, selections: tuple[tuple[str, Any], ...], configurables: tuple[tuple[str, str], ...]
|
|
41
|
+
) -> Dashboard:
|
|
42
|
+
"""Cachable version of dashboard results helper"""
|
|
43
|
+
return await self.do_cachable_action(self.dashboard_results_cache, self._get_dashboard_results_helper, dashboard, user, selections, configurables)
|
|
44
|
+
|
|
45
|
+
async def _get_dashboard_results_definition(
|
|
46
|
+
self, dashboard_name: str, user: AbstractUser, all_request_params: dict, params: dict, headers: dict[str, str]
|
|
47
|
+
) -> Response:
|
|
48
|
+
"""Get dashboard results definition"""
|
|
49
|
+
self._validate_request_params(all_request_params, params, headers)
|
|
50
|
+
|
|
51
|
+
get_dashboard_function = self._get_dashboard_results_helper if self.no_cache else self._get_dashboard_results_cachable
|
|
52
|
+
selections = self.get_selections_as_immutable(params, uncached_keys={"x_verify_params"})
|
|
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)
|
|
57
|
+
|
|
58
|
+
if dashboard_obj._format == c.PNG:
|
|
59
|
+
assert isinstance(dashboard_obj._content, bytes)
|
|
60
|
+
result = Response(dashboard_obj._content, media_type="image/png")
|
|
61
|
+
elif dashboard_obj._format == c.HTML:
|
|
62
|
+
result = HTMLResponse(dashboard_obj._content)
|
|
63
|
+
else:
|
|
64
|
+
raise NotImplementedError()
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def setup_routes(
|
|
68
|
+
self, app: FastAPI, project_metadata_path: str, param_fields: dict, get_parameters_definition: Callable[..., Coroutine[Any, Any, rm.ParametersModel]]
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Setup dashboard routes"""
|
|
71
|
+
|
|
72
|
+
dashboard_results_path = project_metadata_path + '/dashboard/{dashboard}'
|
|
73
|
+
dashboard_parameters_path = dashboard_results_path + '/parameters'
|
|
74
|
+
|
|
75
|
+
def validate_parameters_list(parameters: list[str] | None, entity_type: str, dashboard_name: str) -> None:
|
|
76
|
+
if parameters is None:
|
|
77
|
+
return
|
|
78
|
+
for param in parameters:
|
|
79
|
+
if param not in param_fields:
|
|
80
|
+
all_params = list(param_fields.keys())
|
|
81
|
+
raise ConfigurationError(
|
|
82
|
+
f"{entity_type} '{dashboard_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
|
|
83
|
+
f"\n {all_params}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Dashboard parameters and results APIs
|
|
87
|
+
for dashboard_name, dashboard in self.project._dashboards.items():
|
|
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)
|
|
91
|
+
|
|
92
|
+
validate_parameters_list(dashboard.config.parameters, "Dashboard", dashboard_name)
|
|
93
|
+
|
|
94
|
+
QueryModelForGetParams, QueryModelForPostParams = get_query_models_for_parameters(dashboard.config.parameters, param_fields)
|
|
95
|
+
QueryModelForGetDash, QueryModelForPostDash = get_query_models_for_dashboard(dashboard.config.parameters, param_fields)
|
|
96
|
+
|
|
97
|
+
@app.get(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
|
|
98
|
+
async def get_dashboard_parameters(
|
|
99
|
+
request: Request, params: QueryModelForGetParams, user=Depends(self.get_current_user) # type: ignore
|
|
100
|
+
) -> rm.ParametersModel:
|
|
101
|
+
start = time.time()
|
|
102
|
+
curr_dashboard_name = self.get_name_from_path_section(request, -2)
|
|
103
|
+
parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
|
|
104
|
+
scope = self.project._dashboards[curr_dashboard_name].config.scope
|
|
105
|
+
result = await get_parameters_definition(
|
|
106
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
|
|
107
|
+
)
|
|
108
|
+
self.log_activity_time("GET REQUEST for PARAMETERS", start, request)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
@app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=self._parameters_description, response_class=JSONResponse)
|
|
112
|
+
async def get_dashboard_parameters_with_post(
|
|
113
|
+
request: Request, params: QueryModelForPostParams, user=Depends(self.get_current_user) # type: ignore
|
|
114
|
+
) -> rm.ParametersModel:
|
|
115
|
+
start = time.time()
|
|
116
|
+
curr_dashboard_name = self.get_name_from_path_section(request, -2)
|
|
117
|
+
parameters_list = self.project._dashboards[curr_dashboard_name].config.parameters
|
|
118
|
+
scope = self.project._dashboards[curr_dashboard_name].config.scope
|
|
119
|
+
payload: dict = await request.json()
|
|
120
|
+
result = await get_parameters_definition(
|
|
121
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump(), headers=dict(request.headers)
|
|
122
|
+
)
|
|
123
|
+
self.log_activity_time("POST REQUEST for PARAMETERS", start, request)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
127
|
+
async def get_dashboard_results(
|
|
128
|
+
request: Request, params: QueryModelForGetDash, user=Depends(self.get_current_user) # type: ignore
|
|
129
|
+
) -> Response:
|
|
130
|
+
start = time.time()
|
|
131
|
+
curr_dashboard_name = self.get_name_from_path_section(request, -1)
|
|
132
|
+
result = await self._get_dashboard_results_definition(
|
|
133
|
+
curr_dashboard_name, user, dict(request.query_params), asdict(params), headers=dict(request.headers)
|
|
134
|
+
)
|
|
135
|
+
self.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
139
|
+
async def get_dashboard_results_with_post(
|
|
140
|
+
request: Request, params: QueryModelForPostDash, user=Depends(self.get_current_user) # type: ignore
|
|
141
|
+
) -> Response:
|
|
142
|
+
start = time.time()
|
|
143
|
+
curr_dashboard_name = self.get_name_from_path_section(request, -1)
|
|
144
|
+
payload: dict = await request.json()
|
|
145
|
+
result = await self._get_dashboard_results_definition(
|
|
146
|
+
curr_dashboard_name, user, payload, params.model_dump(), headers=dict(request.headers)
|
|
147
|
+
)
|
|
148
|
+
self.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request)
|
|
149
|
+
return result
|
|
150
|
+
|