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.
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
@@ -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]