lightspeed-stack 0.1.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.
- app/__init__.py +1 -0
- app/endpoints/.ruff_cache/.gitignore +2 -0
- app/endpoints/.ruff_cache/0.9.1/5703048272820174433 +0 -0
- app/endpoints/.ruff_cache/0.9.1/9961612457335986079 +0 -0
- app/endpoints/.ruff_cache/CACHEDIR.TAG +1 -0
- app/endpoints/__init__.py +1 -0
- app/endpoints/config.py +64 -0
- app/endpoints/feedback.py +129 -0
- app/endpoints/health.py +111 -0
- app/endpoints/info.py +26 -0
- app/endpoints/models.py +79 -0
- app/endpoints/query.py +360 -0
- app/endpoints/root.py +777 -0
- app/endpoints/streaming_query.py +321 -0
- app/main.py +38 -0
- app/routers.py +30 -0
- auth/__init__.py +38 -0
- auth/interface.py +13 -0
- auth/k8s.py +270 -0
- auth/noop.py +42 -0
- auth/noop_with_token.py +46 -0
- auth/utils.py +26 -0
- lightspeed_stack-0.1.0.dist-info/METADATA +443 -0
- lightspeed_stack-0.1.0.dist-info/RECORD +43 -0
- lightspeed_stack-0.1.0.dist-info/WHEEL +4 -0
- lightspeed_stack-0.1.0.dist-info/entry_points.txt +4 -0
- lightspeed_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
- models/__init__.py +1 -0
- models/config.py +161 -0
- models/requests.py +208 -0
- models/responses.py +244 -0
- runners/__init__.py +1 -0
- runners/uvicorn.py +31 -0
- utils/.ruff_cache/.gitignore +2 -0
- utils/.ruff_cache/0.9.1/18446581155718949728 +0 -0
- utils/.ruff_cache/0.9.1/4991844299736624256 +0 -0
- utils/.ruff_cache/CACHEDIR.TAG +1 -0
- utils/__init__.py +1 -0
- utils/checks.py +27 -0
- utils/common.py +111 -0
- utils/endpoints.py +34 -0
- utils/mcp_headers.py +48 -0
- utils/suid.py +28 -0
auth/k8s.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Manage authentication flow for FastAPI endpoints with K8S/OCP."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Self
|
|
7
|
+
from fastapi import Request, HTTPException
|
|
8
|
+
|
|
9
|
+
import kubernetes.client
|
|
10
|
+
from kubernetes.client.rest import ApiException
|
|
11
|
+
from kubernetes.config import ConfigException
|
|
12
|
+
|
|
13
|
+
from configuration import configuration
|
|
14
|
+
from auth.utils import extract_user_token
|
|
15
|
+
from auth.interface import AuthInterface
|
|
16
|
+
from constants import DEFAULT_VIRTUAL_PATH
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CLUSTER_ID_LOCAL = "local"
|
|
22
|
+
RUNNING_IN_CLUSTER = (
|
|
23
|
+
"KUBERNETES_SERVICE_HOST" in os.environ and "KUBERNETES_SERVICE_PORT" in os.environ
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClusterIDUnavailableError(Exception):
|
|
28
|
+
"""Cluster ID is not available."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class K8sClientSingleton:
|
|
32
|
+
"""Return the Kubernetes client instances.
|
|
33
|
+
|
|
34
|
+
Ensures we initialize the k8s client only once per application life cycle.
|
|
35
|
+
manage the initialization and config loading.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_instance = None
|
|
39
|
+
_api_client = None
|
|
40
|
+
_authn_api: kubernetes.client.AuthenticationV1Api
|
|
41
|
+
_authz_api: kubernetes.client.AuthorizationV1Api
|
|
42
|
+
_cluster_id = None
|
|
43
|
+
|
|
44
|
+
def __new__(cls: type[Self]) -> Self:
|
|
45
|
+
"""Create a new instance of the singleton, or returns the existing instance.
|
|
46
|
+
|
|
47
|
+
This method initializes the Kubernetes API clients the first time it is called.
|
|
48
|
+
and ensures that subsequent calls return the same instance.
|
|
49
|
+
"""
|
|
50
|
+
if cls._instance is None:
|
|
51
|
+
cls._instance = super().__new__(cls)
|
|
52
|
+
k8s_config = kubernetes.client.Configuration()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
try:
|
|
56
|
+
logger.info("loading in-cluster config")
|
|
57
|
+
kubernetes.config.load_incluster_config(
|
|
58
|
+
client_configuration=k8s_config
|
|
59
|
+
)
|
|
60
|
+
except ConfigException as e:
|
|
61
|
+
logger.debug("unable to load in-cluster config: %s", e)
|
|
62
|
+
try:
|
|
63
|
+
logger.info("loading config from kube-config file")
|
|
64
|
+
kubernetes.config.load_kube_config(
|
|
65
|
+
client_configuration=k8s_config
|
|
66
|
+
)
|
|
67
|
+
except ConfigException as ce:
|
|
68
|
+
logger.error(
|
|
69
|
+
"failed to load kubeconfig, in-cluster config\
|
|
70
|
+
and no override token was provided: %s",
|
|
71
|
+
ce,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
k8s_config.host = (
|
|
75
|
+
configuration.authentication_configuration.k8s_cluster_api
|
|
76
|
+
or k8s_config.host
|
|
77
|
+
)
|
|
78
|
+
k8s_config.verify_ssl = (
|
|
79
|
+
not configuration.authentication_configuration.skip_tls_verification
|
|
80
|
+
)
|
|
81
|
+
k8s_config.ssl_ca_cert = (
|
|
82
|
+
configuration.authentication_configuration.k8s_ca_cert_path
|
|
83
|
+
if configuration.authentication_configuration.k8s_ca_cert_path
|
|
84
|
+
not in {None, Path()}
|
|
85
|
+
else k8s_config.ssl_ca_cert
|
|
86
|
+
)
|
|
87
|
+
api_client = kubernetes.client.ApiClient(k8s_config)
|
|
88
|
+
cls._api_client = api_client
|
|
89
|
+
cls._custom_objects_api = kubernetes.client.CustomObjectsApi(api_client)
|
|
90
|
+
cls._authn_api = kubernetes.client.AuthenticationV1Api(api_client)
|
|
91
|
+
cls._authz_api = kubernetes.client.AuthorizationV1Api(api_client)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.info("Failed to initialize Kubernetes client: %s", e)
|
|
94
|
+
raise
|
|
95
|
+
return cls._instance
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def get_authn_api(cls) -> kubernetes.client.AuthenticationV1Api:
|
|
99
|
+
"""Return the Authentication API client instance.
|
|
100
|
+
|
|
101
|
+
Ensures the singleton is initialized before returning the Authentication API client.
|
|
102
|
+
"""
|
|
103
|
+
if cls._instance is None or cls._authn_api is None:
|
|
104
|
+
cls()
|
|
105
|
+
return cls._authn_api
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def get_authz_api(cls) -> kubernetes.client.AuthorizationV1Api:
|
|
109
|
+
"""Return the Authorization API client instance.
|
|
110
|
+
|
|
111
|
+
Ensures the singleton is initialized before returning the Authorization API client.
|
|
112
|
+
"""
|
|
113
|
+
if cls._instance is None or cls._authz_api is None:
|
|
114
|
+
cls()
|
|
115
|
+
return cls._authz_api
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def get_custom_objects_api(cls) -> kubernetes.client.CustomObjectsApi:
|
|
119
|
+
"""Return the custom objects API instance.
|
|
120
|
+
|
|
121
|
+
Ensures the singleton is initialized before returning the Authorization API client.
|
|
122
|
+
"""
|
|
123
|
+
if cls._instance is None or cls._custom_objects_api is None:
|
|
124
|
+
cls()
|
|
125
|
+
return cls._custom_objects_api
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def _get_cluster_id(cls) -> str:
|
|
129
|
+
try:
|
|
130
|
+
custom_objects_api = cls.get_custom_objects_api()
|
|
131
|
+
version_data = custom_objects_api.get_cluster_custom_object(
|
|
132
|
+
"config.openshift.io", "v1", "clusterversions", "version"
|
|
133
|
+
)
|
|
134
|
+
cluster_id = version_data["spec"]["clusterID"]
|
|
135
|
+
cls._cluster_id = cluster_id
|
|
136
|
+
return cluster_id
|
|
137
|
+
except KeyError as e:
|
|
138
|
+
logger.error(
|
|
139
|
+
"Failed to get cluster_id from cluster, missing keys in version object"
|
|
140
|
+
)
|
|
141
|
+
raise ClusterIDUnavailableError("Failed to get cluster ID") from e
|
|
142
|
+
except TypeError as e:
|
|
143
|
+
logger.error(
|
|
144
|
+
"Failed to get cluster_id, version object is: %s", version_data
|
|
145
|
+
)
|
|
146
|
+
raise ClusterIDUnavailableError("Failed to get cluster ID") from e
|
|
147
|
+
except ApiException as e:
|
|
148
|
+
logger.error("API exception during ClusterInfo: %s", e)
|
|
149
|
+
raise ClusterIDUnavailableError("Failed to get cluster ID") from e
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error("Unexpected error during getting cluster ID: %s", e)
|
|
152
|
+
raise ClusterIDUnavailableError("Failed to get cluster ID") from e
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def get_cluster_id(cls) -> str:
|
|
156
|
+
"""Return the cluster ID."""
|
|
157
|
+
if cls._instance is None:
|
|
158
|
+
cls()
|
|
159
|
+
if cls._cluster_id is None:
|
|
160
|
+
if RUNNING_IN_CLUSTER:
|
|
161
|
+
cls._cluster_id = cls._get_cluster_id()
|
|
162
|
+
else:
|
|
163
|
+
logger.debug("Not running in cluster, setting cluster_id to 'local'")
|
|
164
|
+
cls._cluster_id = CLUSTER_ID_LOCAL
|
|
165
|
+
return cls._cluster_id
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_user_info(token: str) -> Optional[kubernetes.client.V1TokenReview]:
|
|
169
|
+
"""Perform a Kubernetes TokenReview to validate a given token.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
token: The bearer token to be validated.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The user information if the token is valid, None otherwise.
|
|
176
|
+
"""
|
|
177
|
+
auth_api = K8sClientSingleton.get_authn_api()
|
|
178
|
+
token_review = kubernetes.client.V1TokenReview(
|
|
179
|
+
spec=kubernetes.client.V1TokenReviewSpec(token=token)
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
response = auth_api.create_token_review(token_review)
|
|
183
|
+
if response.status.authenticated:
|
|
184
|
+
return response.status
|
|
185
|
+
return None
|
|
186
|
+
except ApiException as e:
|
|
187
|
+
logger.error("API exception during TokenReview: %s", e)
|
|
188
|
+
return None
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error("Unexpected error during TokenReview - Unauthorized: %s", e)
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=500,
|
|
193
|
+
detail={"response": "Forbidden: Unable to Review Token", "cause": str(e)},
|
|
194
|
+
) from e
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _extract_bearer_token(header: str) -> str:
|
|
198
|
+
"""Extract the bearer token from an HTTP authorization header.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
header: The authorization header containing the token.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The extracted token if present, else an empty string.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
scheme, token = header.split(" ", 1)
|
|
208
|
+
return token if scheme.lower() == "bearer" else ""
|
|
209
|
+
except ValueError:
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class K8SAuthDependency(AuthInterface): # pylint: disable=too-few-public-methods
|
|
214
|
+
"""FastAPI dependency for Kubernetes (k8s) authentication and authorization.
|
|
215
|
+
|
|
216
|
+
K8SAuthDependency is an authentication and authorization dependency for FastAPI endpoints,
|
|
217
|
+
integrating with Kubernetes RBAC via SubjectAccessReview (SAR).
|
|
218
|
+
|
|
219
|
+
This class extracts the user token from the request headers, retrieves user information,
|
|
220
|
+
and performs a Kubernetes SAR to determine if the user is authorized.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
HTTPException: HTTP 403 if the token is invalid, expired, or the user is not authorized.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, virtual_path: str = DEFAULT_VIRTUAL_PATH) -> None:
|
|
228
|
+
"""Initialize the required allowed paths for authorization checks."""
|
|
229
|
+
self.virtual_path = virtual_path
|
|
230
|
+
|
|
231
|
+
async def __call__(self, request: Request) -> tuple[str, str, str]:
|
|
232
|
+
"""Validate FastAPI Requests for authentication and authorization.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
request: The FastAPI request object.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The user's UID and username if authentication and authorization succeed
|
|
239
|
+
user_id check is skipped with noop auth to allow consumers provide user_id
|
|
240
|
+
"""
|
|
241
|
+
token = extract_user_token(request.headers)
|
|
242
|
+
user_info = get_user_info(token)
|
|
243
|
+
if user_info is None:
|
|
244
|
+
raise HTTPException(
|
|
245
|
+
status_code=403, detail="Forbidden: Invalid or expired token"
|
|
246
|
+
)
|
|
247
|
+
if user_info.user.username == "kube:admin":
|
|
248
|
+
user_info.user.uid = K8sClientSingleton.get_cluster_id()
|
|
249
|
+
authorization_api = K8sClientSingleton.get_authz_api()
|
|
250
|
+
|
|
251
|
+
sar = kubernetes.client.V1SubjectAccessReview(
|
|
252
|
+
spec=kubernetes.client.V1SubjectAccessReviewSpec(
|
|
253
|
+
user=user_info.user.username,
|
|
254
|
+
groups=user_info.user.groups,
|
|
255
|
+
non_resource_attributes=kubernetes.client.V1NonResourceAttributes(
|
|
256
|
+
path=self.virtual_path, verb="get"
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
response = authorization_api.create_subject_access_review(sar)
|
|
262
|
+
if not response.status.allowed:
|
|
263
|
+
raise HTTPException(
|
|
264
|
+
status_code=403, detail="Forbidden: User does not have access"
|
|
265
|
+
)
|
|
266
|
+
except ApiException as e:
|
|
267
|
+
logger.error("API exception during SubjectAccessReview: %s", e)
|
|
268
|
+
raise HTTPException(status_code=403, detail="Internal server error") from e
|
|
269
|
+
|
|
270
|
+
return user_info.user.uid, user_info.user.username, token
|
auth/noop.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Manage authentication flow for FastAPI endpoints with no-op auth."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
from constants import (
|
|
8
|
+
DEFAULT_USER_NAME,
|
|
9
|
+
DEFAULT_USER_UID,
|
|
10
|
+
NO_USER_TOKEN,
|
|
11
|
+
DEFAULT_VIRTUAL_PATH,
|
|
12
|
+
)
|
|
13
|
+
from auth.interface import AuthInterface
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoopAuthDependency(AuthInterface): # pylint: disable=too-few-public-methods
|
|
19
|
+
"""No-op AuthDependency class that bypasses authentication and authorization checks."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, virtual_path: str = DEFAULT_VIRTUAL_PATH) -> None:
|
|
22
|
+
"""Initialize the required allowed paths for authorization checks."""
|
|
23
|
+
self.virtual_path = virtual_path
|
|
24
|
+
|
|
25
|
+
async def __call__(self, request: Request) -> tuple[str, str, str]:
|
|
26
|
+
"""Validate FastAPI Requests for authentication and authorization.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
request: The FastAPI request object.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The user's UID and username if authentication and authorization succeed
|
|
33
|
+
user_id check is skipped with noop auth to allow consumers provide user_id
|
|
34
|
+
"""
|
|
35
|
+
logger.warning(
|
|
36
|
+
"No-op authentication dependency is being used. "
|
|
37
|
+
"The service is running in insecure mode intended solely for development purposes"
|
|
38
|
+
)
|
|
39
|
+
# try to extract user ID from request
|
|
40
|
+
user_id = request.query_params.get("user_id", DEFAULT_USER_UID)
|
|
41
|
+
logger.debug("Retrieved user ID: %s", user_id)
|
|
42
|
+
return user_id, DEFAULT_USER_NAME, NO_USER_TOKEN
|
auth/noop_with_token.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Manage authentication flow for FastAPI endpoints with no-op auth."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
from constants import (
|
|
8
|
+
DEFAULT_USER_NAME,
|
|
9
|
+
DEFAULT_USER_UID,
|
|
10
|
+
DEFAULT_VIRTUAL_PATH,
|
|
11
|
+
)
|
|
12
|
+
from auth.interface import AuthInterface
|
|
13
|
+
from auth.utils import extract_user_token
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoopWithTokenAuthDependency(
|
|
19
|
+
AuthInterface
|
|
20
|
+
): # pylint: disable=too-few-public-methods
|
|
21
|
+
"""No-op AuthDependency class that bypasses authentication and authorization checks."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, virtual_path: str = DEFAULT_VIRTUAL_PATH) -> None:
|
|
24
|
+
"""Initialize the required allowed paths for authorization checks."""
|
|
25
|
+
self.virtual_path = virtual_path
|
|
26
|
+
|
|
27
|
+
async def __call__(self, request: Request) -> tuple[str, str, str]:
|
|
28
|
+
"""Validate FastAPI Requests for authentication and authorization.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: The FastAPI request object.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The user's UID and username if authentication and authorization succeed
|
|
35
|
+
user_id check is skipped with noop auth to allow consumers provide user_id
|
|
36
|
+
"""
|
|
37
|
+
logger.warning(
|
|
38
|
+
"No-op with token authentication dependency is being used. "
|
|
39
|
+
"The service is running in insecure mode intended solely for development purposes"
|
|
40
|
+
)
|
|
41
|
+
# try to extract user token from request
|
|
42
|
+
user_token = extract_user_token(request.headers)
|
|
43
|
+
# try to extract user ID from request
|
|
44
|
+
user_id = request.query_params.get("user_id", DEFAULT_USER_UID)
|
|
45
|
+
logger.debug("Retrieved user ID: %s", user_id)
|
|
46
|
+
return user_id, DEFAULT_USER_NAME, user_token
|
auth/utils.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Authentication utility functions."""
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from starlette.datastructures import Headers
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_user_token(headers: Headers) -> str:
|
|
8
|
+
"""Extract the bearer token from an HTTP authorization header.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
header: The authorization header containing the token.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The extracted token if present, else an empty string.
|
|
15
|
+
"""
|
|
16
|
+
authorization_header = headers.get("Authorization")
|
|
17
|
+
if not authorization_header:
|
|
18
|
+
raise HTTPException(status_code=400, detail="No Authorization header found")
|
|
19
|
+
|
|
20
|
+
scheme_and_token = authorization_header.strip().split()
|
|
21
|
+
if len(scheme_and_token) != 2 or scheme_and_token[0].lower() != "bearer":
|
|
22
|
+
raise HTTPException(
|
|
23
|
+
status_code=400, detail="No token found in Authorization header"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return scheme_and_token[1]
|