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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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