rubin-gafaelfawr 15.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.
- rubin/gafaelfawr/__init__.py +41 -0
- rubin/gafaelfawr/_client.py +364 -0
- rubin/gafaelfawr/_constants.py +17 -0
- rubin/gafaelfawr/_exceptions.py +81 -0
- rubin/gafaelfawr/_mock.py +280 -0
- rubin/gafaelfawr/_models.py +165 -0
- rubin/gafaelfawr/_tokens.py +28 -0
- rubin/gafaelfawr/py.typed +0 -0
- rubin_gafaelfawr-15.1.0.dist-info/METADATA +43 -0
- rubin_gafaelfawr-15.1.0.dist-info/RECORD +13 -0
- rubin_gafaelfawr-15.1.0.dist-info/WHEEL +5 -0
- rubin_gafaelfawr-15.1.0.dist-info/licenses/LICENSE +22 -0
- rubin_gafaelfawr-15.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Client for Gafaelfawr."""
|
|
2
|
+
|
|
3
|
+
from ._client import GafaelfawrClient
|
|
4
|
+
from ._exceptions import (
|
|
5
|
+
GafaelfawrDiscoveryError,
|
|
6
|
+
GafaelfawrError,
|
|
7
|
+
GafaelfawrNotFoundError,
|
|
8
|
+
GafaelfawrValidationError,
|
|
9
|
+
GafaelfawrWebError,
|
|
10
|
+
)
|
|
11
|
+
from ._mock import (
|
|
12
|
+
MockGafaelfawr,
|
|
13
|
+
MockGafaelfawrAction,
|
|
14
|
+
register_mock_gafaelfawr,
|
|
15
|
+
)
|
|
16
|
+
from ._models import (
|
|
17
|
+
GafaelfawrGroup,
|
|
18
|
+
GafaelfawrNotebookQuota,
|
|
19
|
+
GafaelfawrQuota,
|
|
20
|
+
GafaelfawrTapQuota,
|
|
21
|
+
GafaelfawrUserInfo,
|
|
22
|
+
)
|
|
23
|
+
from ._tokens import create_token
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"GafaelfawrClient",
|
|
27
|
+
"GafaelfawrDiscoveryError",
|
|
28
|
+
"GafaelfawrError",
|
|
29
|
+
"GafaelfawrGroup",
|
|
30
|
+
"GafaelfawrNotFoundError",
|
|
31
|
+
"GafaelfawrNotebookQuota",
|
|
32
|
+
"GafaelfawrQuota",
|
|
33
|
+
"GafaelfawrTapQuota",
|
|
34
|
+
"GafaelfawrUserInfo",
|
|
35
|
+
"GafaelfawrValidationError",
|
|
36
|
+
"GafaelfawrWebError",
|
|
37
|
+
"MockGafaelfawr",
|
|
38
|
+
"MockGafaelfawrAction",
|
|
39
|
+
"create_token",
|
|
40
|
+
"register_mock_gafaelfawr",
|
|
41
|
+
]
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Client for the Gafaelfawr authorization and identity service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
from cachetools import TTLCache
|
|
10
|
+
from httpx import AsyncClient, HTTPError, HTTPStatusError, Timeout
|
|
11
|
+
from pydantic import BaseModel, ValidationError
|
|
12
|
+
from rubin.repertoire import DiscoveryClient
|
|
13
|
+
from structlog.stdlib import BoundLogger
|
|
14
|
+
|
|
15
|
+
from ._constants import CACHE_LIFETIME, CACHE_SIZE
|
|
16
|
+
from ._exceptions import (
|
|
17
|
+
GafaelfawrDiscoveryError,
|
|
18
|
+
GafaelfawrNotFoundError,
|
|
19
|
+
GafaelfawrValidationError,
|
|
20
|
+
GafaelfawrWebError,
|
|
21
|
+
)
|
|
22
|
+
from ._models import (
|
|
23
|
+
AdminTokenRequest,
|
|
24
|
+
GafaelfawrGroup,
|
|
25
|
+
GafaelfawrUserInfo,
|
|
26
|
+
NewToken,
|
|
27
|
+
TokenType,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = ["GafaelfawrClient"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GafaelfawrClient:
|
|
34
|
+
"""Client for the Gafaelfawr service API.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
http_client
|
|
39
|
+
Existing ``httpx.AsyncClient`` to use instead of creating a new one.
|
|
40
|
+
This allows the caller to reuse an existing client and connection
|
|
41
|
+
pool.
|
|
42
|
+
discovery_client
|
|
43
|
+
If given, Repertoire_ discovery client to use. Otherwise, a new client
|
|
44
|
+
will be created.
|
|
45
|
+
logger
|
|
46
|
+
Logger to use. If not given, the default structlog logger will be
|
|
47
|
+
used.
|
|
48
|
+
timeout
|
|
49
|
+
Timeout for Gafaelfawr operations. If not given, defaults to the
|
|
50
|
+
timeout of the underlying HTTPX_ client.
|
|
51
|
+
userinfo_cache_lifetime
|
|
52
|
+
How long to cache user information for a token.
|
|
53
|
+
userinfo_cache_size
|
|
54
|
+
How many cache entries for the cache of user information by token.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
http_client: AsyncClient | None = None,
|
|
60
|
+
*,
|
|
61
|
+
discovery_client: DiscoveryClient | None = None,
|
|
62
|
+
logger: BoundLogger | None = None,
|
|
63
|
+
timeout: timedelta | None = None,
|
|
64
|
+
userinfo_cache_lifetime: timedelta = CACHE_LIFETIME,
|
|
65
|
+
userinfo_cache_size: int = CACHE_SIZE,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._client = http_client or AsyncClient()
|
|
68
|
+
self._discovery = discovery_client or DiscoveryClient(self._client)
|
|
69
|
+
self._logger = logger or structlog.get_logger()
|
|
70
|
+
self._userinfo_cache_lifetime = userinfo_cache_lifetime
|
|
71
|
+
self._userinfo_cache_size = userinfo_cache_size
|
|
72
|
+
|
|
73
|
+
# Whether the HTTP client needs to be explicitly closed because we
|
|
74
|
+
# created it.
|
|
75
|
+
self._close_client = http_client is not None
|
|
76
|
+
|
|
77
|
+
# The default timeout is the underlying timeout of the HTTPX client.
|
|
78
|
+
if timeout is not None:
|
|
79
|
+
self._timeout: float | Timeout = timeout.total_seconds()
|
|
80
|
+
else:
|
|
81
|
+
self._timeout = self._client.timeout
|
|
82
|
+
|
|
83
|
+
# Maintain two caches of user information, one indexed by token (when
|
|
84
|
+
# requesting user information for the user corresponding to the token)
|
|
85
|
+
# and one indexed by username (when requesting user information with a
|
|
86
|
+
# privileged token). Most users of the client will only make one type
|
|
87
|
+
# of call, but hopefully the overhead is tiny.
|
|
88
|
+
self._userinfo_token_cache: TTLCache[str, GafaelfawrUserInfo]
|
|
89
|
+
self._userinfo_token_cache = TTLCache(
|
|
90
|
+
userinfo_cache_size, userinfo_cache_lifetime.total_seconds()
|
|
91
|
+
)
|
|
92
|
+
self._userinfo_token_lock = asyncio.Lock()
|
|
93
|
+
self._userinfo_username_cache: TTLCache[str, GafaelfawrUserInfo]
|
|
94
|
+
self._userinfo_username_cache = TTLCache(
|
|
95
|
+
userinfo_cache_size, userinfo_cache_lifetime.total_seconds()
|
|
96
|
+
)
|
|
97
|
+
self._userinfo_username_lock = asyncio.Lock()
|
|
98
|
+
|
|
99
|
+
async def aclose(self) -> None:
|
|
100
|
+
"""Close the HTTP connection pool, if one wasn't provided.
|
|
101
|
+
|
|
102
|
+
Only closes the pool if a new one was created. Does nothing if an
|
|
103
|
+
external HTTP connection pool was passed into the constructor. The
|
|
104
|
+
object must not be used after calling this method.
|
|
105
|
+
"""
|
|
106
|
+
if self._close_client:
|
|
107
|
+
await self._client.aclose()
|
|
108
|
+
|
|
109
|
+
async def clear_cache(self) -> None:
|
|
110
|
+
"""Clear all internal caches."""
|
|
111
|
+
async with self._userinfo_token_lock:
|
|
112
|
+
self._userinfo_token_cache = TTLCache(
|
|
113
|
+
self._userinfo_cache_size,
|
|
114
|
+
self._userinfo_cache_lifetime.total_seconds(),
|
|
115
|
+
)
|
|
116
|
+
async with self._userinfo_username_lock:
|
|
117
|
+
self._userinfo_username_cache = TTLCache(
|
|
118
|
+
self._userinfo_cache_size,
|
|
119
|
+
self._userinfo_cache_lifetime.total_seconds(),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def create_service_token(
|
|
123
|
+
self,
|
|
124
|
+
token: str,
|
|
125
|
+
username: str,
|
|
126
|
+
*,
|
|
127
|
+
scopes: list[str],
|
|
128
|
+
expires: datetime | None = None,
|
|
129
|
+
name: str | None = None,
|
|
130
|
+
uid: int | None = None,
|
|
131
|
+
gid: int | None = None,
|
|
132
|
+
groups: list[GafaelfawrGroup] | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Create a new service token.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
token
|
|
139
|
+
Token to use to authenticate to the Gafaelfawr API. This token
|
|
140
|
+
must have the ``admin:token`` scope.
|
|
141
|
+
username
|
|
142
|
+
Username for which to create a token. Must begin with ``bot-``.
|
|
143
|
+
scopes
|
|
144
|
+
List of scopes to grant to the new token.
|
|
145
|
+
expires
|
|
146
|
+
Expiration date of te new token, or `None` to create a token that
|
|
147
|
+
never expires.
|
|
148
|
+
name
|
|
149
|
+
Full name override. If `None`, the full name will be determined
|
|
150
|
+
from LDAP if configured, and otherwise not set.
|
|
151
|
+
uid
|
|
152
|
+
UID override. If `None`, the UID will be determined from Firestore
|
|
153
|
+
or LDAP if configured, and otherwise not set.
|
|
154
|
+
gid
|
|
155
|
+
Primary GID override. If `None`, the primary GID will be
|
|
156
|
+
determined from Firestore or LDAP if configured, and otherwise not
|
|
157
|
+
set.
|
|
158
|
+
groups
|
|
159
|
+
Group membership override. If `None`, the group membership will be
|
|
160
|
+
determined from LDAP if configured, and otherwise not set.
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
str
|
|
165
|
+
Newly-created token.
|
|
166
|
+
|
|
167
|
+
Raises
|
|
168
|
+
------
|
|
169
|
+
GafaelfawrValidationError
|
|
170
|
+
Raised if the response from Gafaelfawr is invalid.
|
|
171
|
+
GafaelfawrWebError
|
|
172
|
+
Raised if there is some problem talking to the Gafaelfawr API,
|
|
173
|
+
such as an invalid token or network or service failure.
|
|
174
|
+
rubin.repertoire.RepertoireError
|
|
175
|
+
Raised if there was an error talking to service discovery.
|
|
176
|
+
"""
|
|
177
|
+
url = await self._url_for("tokens")
|
|
178
|
+
request = AdminTokenRequest(
|
|
179
|
+
username=username,
|
|
180
|
+
token_type=TokenType.service,
|
|
181
|
+
scopes=scopes,
|
|
182
|
+
expires=expires,
|
|
183
|
+
name=name,
|
|
184
|
+
uid=uid,
|
|
185
|
+
gid=gid,
|
|
186
|
+
groups=groups,
|
|
187
|
+
)
|
|
188
|
+
result = await self._post(url, NewToken, token, body=request)
|
|
189
|
+
return result.token
|
|
190
|
+
|
|
191
|
+
async def get_user_info(
|
|
192
|
+
self, token: str, username: str | None = None
|
|
193
|
+
) -> GafaelfawrUserInfo:
|
|
194
|
+
"""Get information about the user, with caching.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
token
|
|
199
|
+
Token to use to authenticate to the Gafaelfawr API.
|
|
200
|
+
username
|
|
201
|
+
Username for which to request user information, if given. If not
|
|
202
|
+
given, the user information for the user corresponding to the
|
|
203
|
+
authentication token will be retrieved. This parameter may only be
|
|
204
|
+
provided when authenticating with a token with ``admin:userinfo``
|
|
205
|
+
scope.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
GafaelfawrUserInfo
|
|
210
|
+
User information for the user.
|
|
211
|
+
|
|
212
|
+
Raises
|
|
213
|
+
------
|
|
214
|
+
GafaelfawrNotFoundError
|
|
215
|
+
Raised if no user information for the requested user could be
|
|
216
|
+
found. This will always be the case when the ``username``
|
|
217
|
+
parameter is provided and Gafaelfawr is not configured to use LDAP
|
|
218
|
+
for user information, since in that case user information can only
|
|
219
|
+
be retrieved with a user's token.
|
|
220
|
+
GafaelfawrValidationError
|
|
221
|
+
Raised if the response from Gafaelfawr is invalid.
|
|
222
|
+
GafaelfawrWebError
|
|
223
|
+
Raised if there is some problem talking to the Gafaelfawr API,
|
|
224
|
+
such as an invalid token or network or service failure.
|
|
225
|
+
rubin.repertoire.RepertoireError
|
|
226
|
+
Raised if there was an error talking to service discovery.
|
|
227
|
+
"""
|
|
228
|
+
if username is not None:
|
|
229
|
+
if userinfo := self._userinfo_username_cache.get(username):
|
|
230
|
+
return userinfo
|
|
231
|
+
async with self._userinfo_username_lock:
|
|
232
|
+
if userinfo := self._userinfo_username_cache.get(username):
|
|
233
|
+
return userinfo
|
|
234
|
+
url = await self._url_for(f"users/{username}")
|
|
235
|
+
userinfo = await self._get(url, GafaelfawrUserInfo, token)
|
|
236
|
+
self._userinfo_username_cache[username] = userinfo
|
|
237
|
+
return userinfo
|
|
238
|
+
else:
|
|
239
|
+
if userinfo := self._userinfo_token_cache.get(token):
|
|
240
|
+
return userinfo
|
|
241
|
+
async with self._userinfo_token_lock:
|
|
242
|
+
if userinfo := self._userinfo_token_cache.get(token):
|
|
243
|
+
return userinfo
|
|
244
|
+
url = await self._url_for("user-info")
|
|
245
|
+
userinfo = await self._get(url, GafaelfawrUserInfo, token)
|
|
246
|
+
self._userinfo_token_cache[token] = userinfo
|
|
247
|
+
return userinfo
|
|
248
|
+
|
|
249
|
+
async def _get[T: BaseModel](
|
|
250
|
+
self, url: str, model: type[T], token: str
|
|
251
|
+
) -> T:
|
|
252
|
+
"""Make an HTTP GET request and validate the results.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
url
|
|
257
|
+
URL at which to make the request.
|
|
258
|
+
model
|
|
259
|
+
Expected type of the response.
|
|
260
|
+
token
|
|
261
|
+
Gafaelfawr token used for authentication.
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
pydantic.BaseModel
|
|
266
|
+
Validated model of the requested type.
|
|
267
|
+
|
|
268
|
+
Raises
|
|
269
|
+
------
|
|
270
|
+
GafaelfawrNotFoundError
|
|
271
|
+
Raised if the requested URL returned a 404 response.
|
|
272
|
+
GafaelfawrValidationError
|
|
273
|
+
Raised if the response from Gafaelfawr is invalid.
|
|
274
|
+
GafaelfawrWebError
|
|
275
|
+
Raised if there is some problem talking to the Gafaelfawr API,
|
|
276
|
+
such as an invalid token or network or service failure.
|
|
277
|
+
"""
|
|
278
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
279
|
+
try:
|
|
280
|
+
r = await self._client.get(
|
|
281
|
+
url, headers=headers, timeout=self._timeout
|
|
282
|
+
)
|
|
283
|
+
r.raise_for_status()
|
|
284
|
+
return model.model_validate(r.json())
|
|
285
|
+
except HTTPError as e:
|
|
286
|
+
if isinstance(e, HTTPStatusError):
|
|
287
|
+
if e.response.status_code == 404:
|
|
288
|
+
raise GafaelfawrNotFoundError.from_exception(e) from e
|
|
289
|
+
raise GafaelfawrWebError.from_exception(e) from e
|
|
290
|
+
except ValidationError as e:
|
|
291
|
+
raise GafaelfawrValidationError.from_exception(e) from e
|
|
292
|
+
|
|
293
|
+
async def _post[T: BaseModel](
|
|
294
|
+
self, url: str, model: type[T], token: str, *, body: BaseModel
|
|
295
|
+
) -> T:
|
|
296
|
+
"""Make an HTTP GET request and validate the results.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
url
|
|
301
|
+
URL at which to make the request.
|
|
302
|
+
model
|
|
303
|
+
Expected type of the response.
|
|
304
|
+
token
|
|
305
|
+
Gafaelfawr token used for authentication.
|
|
306
|
+
body
|
|
307
|
+
Pydantic model to send as the POST body.
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
pydantic.BaseModel
|
|
312
|
+
Validated model of the requested type.
|
|
313
|
+
|
|
314
|
+
Raises
|
|
315
|
+
------
|
|
316
|
+
GafaelfawrValidationError
|
|
317
|
+
Raised if the response from Gafaelfawr is invalid.
|
|
318
|
+
GafaelfawrWebError
|
|
319
|
+
Raised if there is some problem talking to the Gafaelfawr API,
|
|
320
|
+
such as an invalid token or network or service failure.
|
|
321
|
+
"""
|
|
322
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
323
|
+
try:
|
|
324
|
+
r = await self._client.post(
|
|
325
|
+
url,
|
|
326
|
+
headers=headers,
|
|
327
|
+
json=body.model_dump(mode="json"),
|
|
328
|
+
timeout=self._timeout,
|
|
329
|
+
)
|
|
330
|
+
r.raise_for_status()
|
|
331
|
+
return model.model_validate(r.json())
|
|
332
|
+
except HTTPError as e:
|
|
333
|
+
raise GafaelfawrWebError.from_exception(e) from e
|
|
334
|
+
except ValidationError as e:
|
|
335
|
+
raise GafaelfawrValidationError.from_exception(e) from e
|
|
336
|
+
|
|
337
|
+
async def _url_for(self, route: str) -> str:
|
|
338
|
+
"""Construct the URL for a Gafaelfawr API route.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
route
|
|
343
|
+
Route relative to the Gafaelfawr API base URL. Must not start with
|
|
344
|
+
``/``.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
str
|
|
349
|
+
Full URL to use.
|
|
350
|
+
|
|
351
|
+
Raises
|
|
352
|
+
------
|
|
353
|
+
GafaelfawrDiscoveryError
|
|
354
|
+
Raised if Gafaelfawr is missing from service discovery.
|
|
355
|
+
rubin.repertoire.RepertoireError
|
|
356
|
+
Raised if there was an error talking to service discovery.
|
|
357
|
+
"""
|
|
358
|
+
base_url = await self._discovery.url_for_internal(
|
|
359
|
+
"gafaelfawr", version="v1"
|
|
360
|
+
)
|
|
361
|
+
if not base_url:
|
|
362
|
+
msg = "gafaelfawr (v1) service not found in service discovery"
|
|
363
|
+
raise GafaelfawrDiscoveryError(msg)
|
|
364
|
+
return f"{base_url.rstrip('/')}/{route}"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for the Gafaelfawr client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
__all__ = ["CACHE_LIFETIME", "CACHE_SIZE"]
|
|
8
|
+
|
|
9
|
+
CACHE_LIFETIME = timedelta(minutes=5)
|
|
10
|
+
"""How long to cache user information retrieved from Gafaelfawr.
|
|
11
|
+
|
|
12
|
+
Gafaelfawr policy says that this should not be any longer than five minutes so
|
|
13
|
+
that changes to the user's groups are picked up correctly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
CACHE_SIZE = 1000
|
|
17
|
+
"""Maximum number of users whose information is cached in memory."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Exceptions for the Gafaelfawr client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Self, override
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
from safir.slack.blockkit import (
|
|
9
|
+
SentryEventInfo,
|
|
10
|
+
SlackCodeBlock,
|
|
11
|
+
SlackException,
|
|
12
|
+
SlackMessage,
|
|
13
|
+
SlackWebException,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"GafaelfawrDiscoveryError",
|
|
18
|
+
"GafaelfawrError",
|
|
19
|
+
"GafaelfawrNotFoundError",
|
|
20
|
+
"GafaelfawrValidationError",
|
|
21
|
+
"GafaelfawrWebError",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GafaelfawrError(SlackException):
|
|
26
|
+
"""Base class for Gafaelfawr client exceptions."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GafaelfawrDiscoveryError(GafaelfawrError):
|
|
30
|
+
"""Gafaelfawr was not found in service discovery."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GafaelfawrValidationError(GafaelfawrError):
|
|
34
|
+
"""Gafaelfawr response did not validate against the expected model."""
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_exception(cls, exc: ValidationError) -> Self:
|
|
38
|
+
"""Create an exception from a Pydantic parse failure.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
exc
|
|
43
|
+
Pydantic exception.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
GafaelfawrValidationError
|
|
48
|
+
Constructed exception.
|
|
49
|
+
"""
|
|
50
|
+
error = f"{type(exc).__name__}: {exc!s}"
|
|
51
|
+
return cls("Unable to parse reply from Gafaelfawr", error)
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, error: str) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self._message = message
|
|
56
|
+
self.error = error
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return f"{self._message}: {self.error}"
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
def to_slack(self) -> SlackMessage:
|
|
64
|
+
message = super().to_slack()
|
|
65
|
+
block = SlackCodeBlock(heading="Validation error", code=self.error)
|
|
66
|
+
message.attachments.append(block)
|
|
67
|
+
return message
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def to_sentry(self) -> SentryEventInfo:
|
|
71
|
+
info = super().to_sentry()
|
|
72
|
+
info.contexts["validation_info"] = {"error": self.error}
|
|
73
|
+
return info
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GafaelfawrWebError(SlackWebException, GafaelfawrError):
|
|
77
|
+
"""An HTTP request failed."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GafaelfawrNotFoundError(GafaelfawrWebError):
|
|
81
|
+
"""An HTTP request failed with a 404 response."""
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Mock for the parts of the Gafaelfawr API used by the client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from typing import TYPE_CHECKING, Concatenate
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
from httpx import Request, Response
|
|
15
|
+
from rubin.repertoire import DiscoveryClient
|
|
16
|
+
|
|
17
|
+
from ._models import AdminTokenRequest, GafaelfawrUserInfo, NewToken, TokenData
|
|
18
|
+
from ._tokens import create_token
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import respx
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MockGafaelfawr",
|
|
25
|
+
"MockGafaelfawrAction",
|
|
26
|
+
"register_mock_gafaelfawr",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MockGafaelfawrAction(Enum):
|
|
31
|
+
"""Possible actions that could fail."""
|
|
32
|
+
|
|
33
|
+
CREATE_TOKEN = "create_token"
|
|
34
|
+
USER_INFO = "user_info"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MockGafaelfawr:
|
|
38
|
+
"""Mock for the parts of the Gafaelfawr API used by the client."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._fail: defaultdict[str, set[MockGafaelfawrAction]]
|
|
42
|
+
self._fail = defaultdict(set)
|
|
43
|
+
self._tokens: dict[str, TokenData] = {}
|
|
44
|
+
self._user_info: dict[str, GafaelfawrUserInfo | None] = {}
|
|
45
|
+
|
|
46
|
+
def create_token(
|
|
47
|
+
self,
|
|
48
|
+
username: str,
|
|
49
|
+
*,
|
|
50
|
+
expires: datetime | None = None,
|
|
51
|
+
scopes: Iterable[str] | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Create a token for the given username.
|
|
54
|
+
|
|
55
|
+
This token will only be recognized by the same instance of the
|
|
56
|
+
Gafaelfawr mock.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
username
|
|
61
|
+
Username the token is for.
|
|
62
|
+
expires
|
|
63
|
+
If provided, sets the expiration time of the token.
|
|
64
|
+
scopes
|
|
65
|
+
If provided, list of scopes to assign to the token. This is
|
|
66
|
+
primarily needed if the client will call a privileged API
|
|
67
|
+
endpoint.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
str
|
|
72
|
+
New Gafaelfawr token.
|
|
73
|
+
"""
|
|
74
|
+
token = create_token()
|
|
75
|
+
self._tokens[token] = TokenData(
|
|
76
|
+
token=token,
|
|
77
|
+
username=username,
|
|
78
|
+
expires=expires,
|
|
79
|
+
scopes=set(scopes) if scopes else set(),
|
|
80
|
+
)
|
|
81
|
+
return token
|
|
82
|
+
|
|
83
|
+
def fail_on(
|
|
84
|
+
self,
|
|
85
|
+
username: str,
|
|
86
|
+
actions: MockGafaelfawrAction | Iterable[MockGafaelfawrAction],
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Configure the API to fail on requests for the given user.
|
|
89
|
+
|
|
90
|
+
This can be used by test suites to test handling of Gafaelfawr
|
|
91
|
+
failures.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
username
|
|
96
|
+
Username for which operations should fail.
|
|
97
|
+
actions
|
|
98
|
+
An action or iterable of actions that should fail. Pass in the
|
|
99
|
+
empty list to restore regular operations for this user.
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(actions, MockGafaelfawrAction):
|
|
102
|
+
self._fail[username] = {actions}
|
|
103
|
+
else:
|
|
104
|
+
self._fail[username] = set(actions)
|
|
105
|
+
|
|
106
|
+
def install_routes(self, respx_mock: respx.Router, base_url: str) -> None:
|
|
107
|
+
"""Install the mock routes for the Gafaelfawr API.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
respx_mock
|
|
112
|
+
Mock router to use to install routes.
|
|
113
|
+
base_url
|
|
114
|
+
Base URL for the mock routes.
|
|
115
|
+
"""
|
|
116
|
+
prefix = base_url.rstrip("/") + "/"
|
|
117
|
+
handler = self._handle_user_info
|
|
118
|
+
respx_mock.get(urljoin(prefix, "user-info")).mock(side_effect=handler)
|
|
119
|
+
handler = self._handle_create_token
|
|
120
|
+
respx_mock.post(urljoin(prefix, "tokens")).mock(side_effect=handler)
|
|
121
|
+
|
|
122
|
+
# These routes require regex matching of the username.
|
|
123
|
+
base_regex = re.escape(base_url.rstrip("/"))
|
|
124
|
+
regex = re.compile(base_regex + "/users/(?P<username>[^/]+)$")
|
|
125
|
+
respx_mock.get(url__regex=regex).mock(side_effect=self._handle_user)
|
|
126
|
+
|
|
127
|
+
def set_user_info(
|
|
128
|
+
self, username: str, user_info: GafaelfawrUserInfo | None
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Set the user information for a given user.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
username
|
|
135
|
+
Username for which to set a quota.
|
|
136
|
+
user_info
|
|
137
|
+
User information to return for that user, or `None` to return a
|
|
138
|
+
404 error.
|
|
139
|
+
"""
|
|
140
|
+
assert user_info is None or user_info.username == username, (
|
|
141
|
+
f"User info for wrong user ({user_info.username} != {username}"
|
|
142
|
+
)
|
|
143
|
+
self._user_info[username] = user_info
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _check[**P](
|
|
147
|
+
*,
|
|
148
|
+
fail_on: MockGafaelfawrAction | None = None,
|
|
149
|
+
required_scope: str | None = None,
|
|
150
|
+
) -> Callable[
|
|
151
|
+
[
|
|
152
|
+
Callable[
|
|
153
|
+
Concatenate[MockGafaelfawr, Request, TokenData, P], Response
|
|
154
|
+
]
|
|
155
|
+
],
|
|
156
|
+
Callable[Concatenate[MockGafaelfawr, Request, P], Response],
|
|
157
|
+
]:
|
|
158
|
+
"""Wrap `MockGafaelfawr` methods to perform common checks.
|
|
159
|
+
|
|
160
|
+
There are various common checks that should be performed for every
|
|
161
|
+
request to the mock, and the token always has to be extracted from the
|
|
162
|
+
requst and injected as an additional argument to the method. This
|
|
163
|
+
wrapper performs those checks and then injects the token data into the
|
|
164
|
+
underlying handler.
|
|
165
|
+
|
|
166
|
+
Paramaters
|
|
167
|
+
----------
|
|
168
|
+
fail_on
|
|
169
|
+
If this user is configured to fail on this action, return a
|
|
170
|
+
failure rather than calling the underlying handler.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
typing.Callable
|
|
175
|
+
Decorator to wrap `MockGafaelfawr` methods.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def decorator(
|
|
179
|
+
f: Callable[
|
|
180
|
+
Concatenate[MockGafaelfawr, Request, TokenData, P], Response
|
|
181
|
+
],
|
|
182
|
+
) -> Callable[Concatenate[MockGafaelfawr, Request, P], Response]:
|
|
183
|
+
@wraps(f)
|
|
184
|
+
def wrapper(
|
|
185
|
+
mock: MockGafaelfawr,
|
|
186
|
+
request: Request,
|
|
187
|
+
*args: P.args,
|
|
188
|
+
**kwargs: P.kwargs,
|
|
189
|
+
) -> Response:
|
|
190
|
+
authorization = request.headers["Authorization"]
|
|
191
|
+
scheme, token = authorization.split(None, 1)
|
|
192
|
+
if scheme.lower() != "bearer":
|
|
193
|
+
return Response(403)
|
|
194
|
+
token_data = mock._tokens.get(token)
|
|
195
|
+
if not token_data:
|
|
196
|
+
return Response(403)
|
|
197
|
+
if fail_on and fail_on in mock._fail[token_data.username]:
|
|
198
|
+
return Response(500)
|
|
199
|
+
if required_scope and required_scope not in token_data.scopes:
|
|
200
|
+
return Response(403)
|
|
201
|
+
if token_data.expires:
|
|
202
|
+
if token_data.expires <= datetime.now(tz=UTC):
|
|
203
|
+
return Response(401)
|
|
204
|
+
return f(mock, request, token_data, *args, **kwargs)
|
|
205
|
+
|
|
206
|
+
return wrapper
|
|
207
|
+
|
|
208
|
+
return decorator
|
|
209
|
+
|
|
210
|
+
@_check(
|
|
211
|
+
fail_on=MockGafaelfawrAction.CREATE_TOKEN, required_scope="admin:token"
|
|
212
|
+
)
|
|
213
|
+
def _handle_create_token(
|
|
214
|
+
self, request: Request, token_data: TokenData
|
|
215
|
+
) -> Response:
|
|
216
|
+
body = AdminTokenRequest.model_validate_json(request.content.decode())
|
|
217
|
+
if not body.username.startswith("bot-"):
|
|
218
|
+
return Response(422)
|
|
219
|
+
token = self.create_token(
|
|
220
|
+
body.username,
|
|
221
|
+
expires=body.expires,
|
|
222
|
+
scopes=body.scopes,
|
|
223
|
+
)
|
|
224
|
+
userinfo = GafaelfawrUserInfo(
|
|
225
|
+
username=body.username,
|
|
226
|
+
name=body.name,
|
|
227
|
+
uid=body.uid,
|
|
228
|
+
gid=body.gid,
|
|
229
|
+
groups=body.groups or [],
|
|
230
|
+
)
|
|
231
|
+
self.set_user_info(body.username, userinfo)
|
|
232
|
+
response = NewToken(token=token)
|
|
233
|
+
return Response(200, json=response.model_dump(mode="json"))
|
|
234
|
+
|
|
235
|
+
@_check(required_scope="admin:userinfo")
|
|
236
|
+
def _handle_user(
|
|
237
|
+
self,
|
|
238
|
+
request: Request,
|
|
239
|
+
token_data: TokenData,
|
|
240
|
+
*,
|
|
241
|
+
username: str,
|
|
242
|
+
) -> Response:
|
|
243
|
+
if MockGafaelfawrAction.USER_INFO in self._fail[username]:
|
|
244
|
+
return Response(500)
|
|
245
|
+
elif user_info := self._user_info.get(username):
|
|
246
|
+
result = user_info.model_dump(mode="json", exclude_defaults=True)
|
|
247
|
+
return Response(200, json=result)
|
|
248
|
+
else:
|
|
249
|
+
return Response(404)
|
|
250
|
+
|
|
251
|
+
@_check(fail_on=MockGafaelfawrAction.USER_INFO)
|
|
252
|
+
def _handle_user_info(
|
|
253
|
+
self, request: Request, token_data: TokenData
|
|
254
|
+
) -> Response:
|
|
255
|
+
if user_info := self._user_info.get(token_data.username):
|
|
256
|
+
result = user_info.model_dump(mode="json", exclude_defaults=True)
|
|
257
|
+
return Response(200, json=result)
|
|
258
|
+
else:
|
|
259
|
+
return Response(404)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def register_mock_gafaelfawr(respx_mock: respx.Router) -> MockGafaelfawr:
|
|
263
|
+
"""Mock out Gafaelfawr.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
respx_mock
|
|
268
|
+
Mock router.
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
MockGafaelfawr
|
|
273
|
+
Mock Gafaelfawr API object.
|
|
274
|
+
"""
|
|
275
|
+
discovery_client = DiscoveryClient()
|
|
276
|
+
url = await discovery_client.url_for_internal("gafaelfawr", version="v1")
|
|
277
|
+
assert url, "Service gafaelfawr (v1) not found in Repertoire"
|
|
278
|
+
mock = MockGafaelfawr()
|
|
279
|
+
mock.install_routes(respx_mock, url)
|
|
280
|
+
return mock
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Models for the Gafaelawr API understood by the client.
|
|
2
|
+
|
|
3
|
+
These models are intentionally not shared with the server implementation since
|
|
4
|
+
they may have to handle multiple versions of the server. They are simplified
|
|
5
|
+
compared to the server models, and only the ones starting with ``Gafaelfawr``
|
|
6
|
+
are exposed to users of the module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Annotated, Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AdminTokenRequest",
|
|
19
|
+
"GafaelfawrGroup",
|
|
20
|
+
"GafaelfawrNotebookQuota",
|
|
21
|
+
"GafaelfawrQuota",
|
|
22
|
+
"GafaelfawrTapQuota",
|
|
23
|
+
"GafaelfawrUserInfo",
|
|
24
|
+
"NewToken",
|
|
25
|
+
"TokenData",
|
|
26
|
+
"TokenType",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GafaelfawrGroup(BaseModel):
|
|
31
|
+
"""Information about a single group."""
|
|
32
|
+
|
|
33
|
+
name: Annotated[str, Field(title="Name of the group")]
|
|
34
|
+
|
|
35
|
+
id: Annotated[int, Field(title="Numeric GID of the group")]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GafaelfawrNotebookQuota(BaseModel):
|
|
39
|
+
"""Notebook Aspect quota information for a user."""
|
|
40
|
+
|
|
41
|
+
cpu: Annotated[float, Field(title="CPU equivalents", examples=[4.0])]
|
|
42
|
+
|
|
43
|
+
memory: Annotated[float, Field(title="Maximum memory use (GiB)")]
|
|
44
|
+
|
|
45
|
+
spawn: Annotated[
|
|
46
|
+
bool,
|
|
47
|
+
Field(title="Spawning allowed"),
|
|
48
|
+
] = True
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def memory_bytes(self) -> int:
|
|
52
|
+
"""Maximum memory use in bytes."""
|
|
53
|
+
return int(self.memory * 1024 * 1024 * 1024)
|
|
54
|
+
|
|
55
|
+
def to_logging_context(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert to variables for a structlog logging context."""
|
|
57
|
+
result = {"cpu": self.cpu, "memory": f"{self.memory} GiB"}
|
|
58
|
+
if not self.spawn:
|
|
59
|
+
result["spawn"] = False
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GafaelfawrTapQuota(BaseModel):
|
|
64
|
+
"""TAP quota information for a user."""
|
|
65
|
+
|
|
66
|
+
concurrent: Annotated[int, Field(title="Concurrent queries")]
|
|
67
|
+
|
|
68
|
+
def to_logging_context(self) -> dict[str, Any]:
|
|
69
|
+
"""Convert to variables for a structlog logging context."""
|
|
70
|
+
return {"concurrent": self.concurrent}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GafaelfawrQuota(BaseModel):
|
|
74
|
+
"""Quota information for a user."""
|
|
75
|
+
|
|
76
|
+
api: Annotated[
|
|
77
|
+
dict[str, int],
|
|
78
|
+
Field(
|
|
79
|
+
title="API quotas",
|
|
80
|
+
description=("Mapping of service names to requests per minute"),
|
|
81
|
+
),
|
|
82
|
+
] = {}
|
|
83
|
+
|
|
84
|
+
notebook: Annotated[
|
|
85
|
+
GafaelfawrNotebookQuota | None, Field(title="Notebook Aspect quotas")
|
|
86
|
+
] = None
|
|
87
|
+
|
|
88
|
+
tap: Annotated[
|
|
89
|
+
dict[str, GafaelfawrTapQuota], Field(title="TAP quotas")
|
|
90
|
+
] = {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GafaelfawrUserInfo(BaseModel):
|
|
94
|
+
"""Information about a user."""
|
|
95
|
+
|
|
96
|
+
username: Annotated[str, Field(..., title="Username")]
|
|
97
|
+
|
|
98
|
+
name: Annotated[str | None, Field(title="Preferred full name")] = None
|
|
99
|
+
|
|
100
|
+
email: Annotated[str | None, Field(title="Email address")] = None
|
|
101
|
+
|
|
102
|
+
uid: Annotated[int | None, Field(title="UID number")] = None
|
|
103
|
+
|
|
104
|
+
gid: Annotated[int | None, Field(title="Primary GID")] = None
|
|
105
|
+
|
|
106
|
+
groups: Annotated[list[GafaelfawrGroup], Field(title="Groups")] = []
|
|
107
|
+
|
|
108
|
+
quota: Annotated[GafaelfawrQuota | None, Field(title="Quota")] = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def supplemental_groups(self) -> list[int]:
|
|
112
|
+
"""Supplemental GIDs."""
|
|
113
|
+
return [g.id for g in self.groups]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TokenData(BaseModel):
|
|
117
|
+
"""Metadata about a token, used internally by the mock."""
|
|
118
|
+
|
|
119
|
+
token: Annotated[str, Field(title="Associated token")]
|
|
120
|
+
|
|
121
|
+
username: Annotated[str, Field(title="Username")]
|
|
122
|
+
|
|
123
|
+
scopes: Annotated[set[str], Field(title="Token scopes")] = set()
|
|
124
|
+
|
|
125
|
+
expires: Annotated[datetime | None, Field(title="Expiration time")] = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TokenType(Enum):
|
|
129
|
+
"""The class of token.
|
|
130
|
+
|
|
131
|
+
This includes only the subset of token types used by the client.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
service = "service"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AdminTokenRequest(BaseModel):
|
|
138
|
+
"""A request to create a new token via the admin interface."""
|
|
139
|
+
|
|
140
|
+
username: Annotated[str, Field(title="User for which to issue a token")]
|
|
141
|
+
|
|
142
|
+
token_type: Annotated[TokenType, Field(title="Token type")]
|
|
143
|
+
|
|
144
|
+
scopes: Annotated[list[str], Field(title="Token scopes")] = []
|
|
145
|
+
|
|
146
|
+
expires: Annotated[datetime | None, Field(title="Token expiration")] = None
|
|
147
|
+
|
|
148
|
+
name: Annotated[str | None, Field(title="Preferred full name")] = None
|
|
149
|
+
|
|
150
|
+
email: Annotated[str | None, Field(title="Email address")] = None
|
|
151
|
+
|
|
152
|
+
uid: Annotated[int | None, Field(title="UID number")] = None
|
|
153
|
+
|
|
154
|
+
gid: Annotated[int | None, Field(title="Primary GID")] = None
|
|
155
|
+
|
|
156
|
+
groups: Annotated[
|
|
157
|
+
list[GafaelfawrGroup] | None,
|
|
158
|
+
Field(title="Groups"),
|
|
159
|
+
] = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NewToken(BaseModel):
|
|
163
|
+
"""Response to a token creation request."""
|
|
164
|
+
|
|
165
|
+
token: Annotated[str, Field(title="Newly-created token")]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Functions for creating tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
__all__ = ["create_token"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_token() -> str:
|
|
12
|
+
"""Create a new random Gafaelfawr token.
|
|
13
|
+
|
|
14
|
+
Normally, users of Gafaelfawr should use the Gafaelfawr API to create new
|
|
15
|
+
tokens. This function is intended only for creating new bootstrap tokens
|
|
16
|
+
that will be injected into the Gafaelfawr server via configuration, or for
|
|
17
|
+
creating syntactically valid tokens for use with the Gafaelfawr mock.
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
str
|
|
22
|
+
New random Gafaelfawr token. This token will not be registered with
|
|
23
|
+
any running Gafaelfawr instance and therefore will not be usable
|
|
24
|
+
without other measures, such as configuring it as a bootstrap token.
|
|
25
|
+
"""
|
|
26
|
+
key = base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("=")
|
|
27
|
+
secret = base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("=")
|
|
28
|
+
return f"gt-{key}.{secret}"
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rubin-gafaelfawr
|
|
3
|
+
Version: 15.1.0
|
|
4
|
+
Summary: Client for Gafaelfawr authentication and identity service.
|
|
5
|
+
Author-email: "Association of Universities for Research in Astronomy, Inc. (AURA)" <sqre-admin@lists.lsst.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://gafaelfawr.lsst.io
|
|
8
|
+
Project-URL: Source, https://github.com/lsst-sqre/gafaelfawr
|
|
9
|
+
Project-URL: Change log, https://gafaelfawr.lsst.io/changelog.html
|
|
10
|
+
Project-URL: Issue tracker, https://github.com/lsst-sqre/gafaelfawr/issues
|
|
11
|
+
Keywords: rubin,lsst
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Natural Language :: English
|
|
19
|
+
Classifier: Operating System :: POSIX
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: cachetools<7,>=6.1
|
|
25
|
+
Requires-Dist: httpx<1,>=0.28
|
|
26
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
27
|
+
Requires-Dist: pydantic-settings<3,>=2.6
|
|
28
|
+
Requires-Dist: rubin-repertoire<1,>=0.6
|
|
29
|
+
Requires-Dist: structlog>24
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# rubin-gafaelfawr
|
|
33
|
+
|
|
34
|
+
rubin-gafaelfawr is a client for [Gafaelfawr](https://gafaelfawr.lsst.io), which provides authentication and authorization for [Phalanx](https://phalanx.lsst.io/).
|
|
35
|
+
This package provides the Python client, Pydantic models, and a testing mock for applications that need to use the Gafaelfawr API directly.
|
|
36
|
+
|
|
37
|
+
rubin-gafaelfawr is available from [PyPI](https://pypi.org/project/rubin-gafaelfawr/):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pip install rubin-gafaelfawr
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For full documentation, see [the Gafaelfawr user guide](https://gafaelfawr.lsst.io/user-guide/).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
rubin/gafaelfawr/__init__.py,sha256=KWkUt8P3BhyByQ_NFFvTVyD8yaKCA-Ure5JOeHxnTPA,924
|
|
2
|
+
rubin/gafaelfawr/_client.py,sha256=JXEkaHESqlRhN8PsxDLjgrROWleWUW3IBULfkaH2qvs,13268
|
|
3
|
+
rubin/gafaelfawr/_constants.py,sha256=CzdvNVFxKAaTJk3kFPGuDzgf9XrGZeOSIYS6kilLX8U,490
|
|
4
|
+
rubin/gafaelfawr/_exceptions.py,sha256=-FImkqs_PGvPMA4uYV9H4QGKGESIquGGSFPMLkP-c_k,2097
|
|
5
|
+
rubin/gafaelfawr/_mock.py,sha256=hTtzS4Sj5bLLHvt65GTM3Gc2iGvRrEn7G2xDNhI6sZ8,9075
|
|
6
|
+
rubin/gafaelfawr/_models.py,sha256=AJnG5RX58WlXCOoyylY36eW3zlkjRmJ9jjyocP0a_Q4,4613
|
|
7
|
+
rubin/gafaelfawr/_tokens.py,sha256=ydHPhdZxJoqoUB4giHBVShMtwa2ABjcTG7VXHoDJs9Y,955
|
|
8
|
+
rubin/gafaelfawr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
rubin_gafaelfawr-15.1.0.dist-info/licenses/LICENSE,sha256=doQ1mktl7PtOAr55gjC1t2W-cs_p08b48zuteNGWS3E,1253
|
|
10
|
+
rubin_gafaelfawr-15.1.0.dist-info/METADATA,sha256=43rqQTjzGVhU2bEBFKINQ9qMKQ85ehkl2JGuZBHOKG8,1802
|
|
11
|
+
rubin_gafaelfawr-15.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
rubin_gafaelfawr-15.1.0.dist-info/top_level.txt,sha256=H4T4WkAdqgaG1jgfW1hpFRQhwpqYfHVm3n1gRUpxKcw,6
|
|
13
|
+
rubin_gafaelfawr-15.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2018-2019 The Board of Trustees of the Leland Stanford Junior University, through SLAC National Accelerator Laboratory
|
|
4
|
+
Copyright 2020-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rubin
|