revtrend 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.
revtrend/__init__.py ADDED
File without changes
revtrend/api.py ADDED
@@ -0,0 +1,522 @@
1
+ import asyncio
2
+ import dataclasses
3
+ import fnmatch
4
+ import html
5
+ import json
6
+ import logging
7
+ import re
8
+ import time
9
+ import typing
10
+
11
+ import fastapi
12
+ import fastapi.responses
13
+ import pydantic
14
+
15
+ import revtrend.auth as auth
16
+ import revtrend.config as config
17
+ import revtrend.db as db
18
+ import revtrend.mirror as mirror
19
+ import revtrend.observability as observability
20
+ import revtrend.relay as relay
21
+ import revtrend.store as store
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ router = fastapi.APIRouter()
26
+
27
+ FETCH_TIMEOUT = 60
28
+ MAX_POLL_TIMEOUT = 60
29
+ ON_DEMAND_FETCH_INTERVAL = 30
30
+ METRIC_RE = re.compile(r"^[a-z0-9_.]+$")
31
+ LABEL_KEY_RE = re.compile(r"^[a-z0-9_]+$")
32
+
33
+ Sha = typing.Annotated[str, pydantic.StringConstraints(pattern=r"^[0-9a-f]{40}$")]
34
+ MetricName = typing.Annotated[
35
+ str, pydantic.StringConstraints(pattern=METRIC_RE.pattern, max_length=128)
36
+ ]
37
+ LabelKey = typing.Annotated[
38
+ str, pydantic.StringConstraints(pattern=LABEL_KEY_RE.pattern, max_length=64)
39
+ ]
40
+ LabelValue = typing.Annotated[
41
+ str, pydantic.StringConstraints(min_length=1, max_length=256)
42
+ ]
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class Project:
47
+ config: config.Project
48
+ id: int
49
+ mirror: mirror.Mirror
50
+
51
+
52
+ @dataclasses.dataclass
53
+ class State:
54
+ config: config.Config
55
+ db: db.Database
56
+ auth: auth.Authenticator
57
+ projects: dict[str, Project]
58
+ relay: relay.Relay
59
+
60
+
61
+ def get_state(request: fastapi.Request):
62
+ return request.app.state.revtrend
63
+
64
+
65
+ async def get_principal(
66
+ state: typing.Annotated[State, fastapi.Depends(get_state)],
67
+ authorization: typing.Annotated[str | None, fastapi.Header()] = None,
68
+ ):
69
+ scheme, _, token = (authorization or "").partition(" ")
70
+ if scheme.lower() != "bearer" or not token:
71
+ raise auth.AuthError("missing bearer token")
72
+ return await state.auth.authenticate(token.strip())
73
+
74
+
75
+ def project_access(role):
76
+ async def dependency(
77
+ project: str,
78
+ state: typing.Annotated[State, fastapi.Depends(get_state)],
79
+ principal: typing.Annotated[auth.Principal, fastapi.Depends(get_principal)],
80
+ ):
81
+ if project not in state.projects:
82
+ raise fastapi.HTTPException(404, f"unknown project {project!r}")
83
+ if not principal.has_role(project, role):
84
+ raise fastapi.HTTPException(
85
+ 403, f"role {role!r} required for project {project!r}"
86
+ )
87
+ return Access(state=state, project=state.projects[project], principal=principal)
88
+
89
+ return dependency
90
+
91
+
92
+ @dataclasses.dataclass
93
+ class Access:
94
+ state: State
95
+ project: Project
96
+ principal: auth.Principal
97
+
98
+
99
+ StateDep = typing.Annotated[State, fastapi.Depends(get_state)]
100
+ PrincipalDep = typing.Annotated[auth.Principal, fastapi.Depends(get_principal)]
101
+ ReaderDep = typing.Annotated[Access, fastapi.Depends(project_access("reader"))]
102
+ WriterDep = typing.Annotated[Access, fastapi.Depends(project_access("writer"))]
103
+ AdminDep = typing.Annotated[Access, fastapi.Depends(project_access("admin"))]
104
+ Aggregation = typing.Literal[tuple(store.AGGREGATIONS)]
105
+ MetricsQuery = typing.Annotated[list[str], fastapi.Query(alias="metric")]
106
+ LabelsQuery = typing.Annotated[list[str], fastapi.Query()]
107
+
108
+
109
+ @router.get("/metrics", include_in_schema=False)
110
+ async def prometheus_metrics():
111
+ content, media_type = observability.metrics_response()
112
+ return fastapi.Response(content, media_type=media_type)
113
+
114
+
115
+ @router.get("/healthz")
116
+ async def healthz(state: StateDep):
117
+ return {
118
+ "status": "ok",
119
+ "projects": {
120
+ name: {"ready": p.mirror.ready} for name, p in state.projects.items()
121
+ },
122
+ }
123
+
124
+
125
+ class Metric(pydantic.BaseModel):
126
+ model_config = pydantic.ConfigDict(extra="forbid")
127
+ name: MetricName
128
+ value: pydantic.FiniteFloat
129
+ labels: typing.Annotated[
130
+ dict[LabelKey, LabelValue], pydantic.Field(max_length=16)
131
+ ] = {}
132
+
133
+
134
+ class Measurements(pydantic.BaseModel):
135
+ model_config = pydantic.ConfigDict(extra="forbid")
136
+ sha: Sha
137
+ run_id: typing.Annotated[
138
+ str, pydantic.StringConstraints(min_length=1, max_length=512)
139
+ ]
140
+ metrics: typing.Annotated[
141
+ list[Metric], pydantic.Field(min_length=1, max_length=10000)
142
+ ]
143
+
144
+ @pydantic.model_validator(mode="after")
145
+ def unique_series(self):
146
+ keys = [(m.name, tuple(sorted(m.labels.items()))) for m in self.metrics]
147
+ if len(keys) != len(set(keys)):
148
+ raise ValueError("duplicate metric name/labels combination")
149
+ return self
150
+
151
+
152
+ @router.post("/api/v1/projects/{project}/measurements", status_code=201)
153
+ async def ingest(body: Measurements, access: WriterDep, response: fastapi.Response):
154
+ await require_commit(access.project.mirror, body.sha)
155
+ principal = access.principal
156
+ run = store.Run(
157
+ project_id=access.project.id,
158
+ sha=body.sha,
159
+ run_id=body.run_id,
160
+ source=principal.source,
161
+ submitted_by=principal.submitted_by,
162
+ submitted_name=principal.display_name,
163
+ measured_at=int(time.time()),
164
+ )
165
+ metrics = [(m.name, m.labels, m.value) for m in body.metrics]
166
+ try:
167
+ created = await access.state.db.write(store.insert_measurements, run, metrics)
168
+ except store.ConflictError as e:
169
+ raise fastapi.HTTPException(409, str(e)) from e
170
+ log.info(
171
+ "ingested %d of %d measurements",
172
+ created,
173
+ len(body.metrics),
174
+ extra={
175
+ "project": access.project.config.name,
176
+ "sha": body.sha,
177
+ "run_id": body.run_id,
178
+ "source": principal.source,
179
+ "submitted_by": principal.submitted_by,
180
+ "submitted_name": principal.display_name,
181
+ },
182
+ )
183
+ observability.INGESTED.labels(access.project.config.name, principal.source).inc(
184
+ created
185
+ )
186
+ if not created:
187
+ response.status_code = 200
188
+ return {"created": created, "existing": len(body.metrics) - created}
189
+
190
+
191
+ async def require_commit(m, sha):
192
+ """Fetches if `sha` is unknown. Only commits that exist in the remote are accepted."""
193
+ if await m.has_commit(sha):
194
+ return
195
+ requested = time.monotonic()
196
+ try:
197
+ await asyncio.wait_for(
198
+ asyncio.shield(m.fetch(not_before=requested)), FETCH_TIMEOUT
199
+ )
200
+ except (TimeoutError, mirror.GitError) as e:
201
+ log.warning("fetch for unknown commit %s failed: %s", sha, e)
202
+ raise fastapi.HTTPException(503, "fetching from remote failed") from e
203
+ if not await m.has_commit(sha):
204
+ raise fastapi.HTTPException(409, f"commit {sha} not found in remote")
205
+
206
+
207
+ @router.get("/api/v1/projects/{project}/series")
208
+ async def series( # noqa: PLR0913
209
+ access: ReaderDep,
210
+ metrics: MetricsQuery,
211
+ ref: str = "default",
212
+ base: str = "default",
213
+ from_ms: typing.Annotated[int | None, fastapi.Query(alias="from")] = None,
214
+ to_ms: typing.Annotated[int | None, fastapi.Query(alias="to")] = None,
215
+ labels: LabelsQuery = (),
216
+ agg: Aggregation = "median",
217
+ ):
218
+ metric_names = parse_metrics(metrics)
219
+ label_filters = parse_label_filters(labels)
220
+ m = access.project.mirror
221
+ ref_sha = await resolve(m, ref)
222
+ base_sha = await resolve(m, base)
223
+ history = await m.history(ref_sha)
224
+ on_branch = await m.branch_only(base_sha, ref_sha)
225
+ commits = [
226
+ (idx, c)
227
+ for idx, c in enumerate(history)
228
+ if (from_ms is None or c.time * 1000 >= from_ms)
229
+ and (to_ms is None or c.time * 1000 <= to_ms)
230
+ ]
231
+ rows = await access.state.db.read(
232
+ store.measurements,
233
+ access.project.id,
234
+ [c.sha for _, c in commits],
235
+ metric_names,
236
+ label_filters,
237
+ )
238
+ points = store.aggregate(rows, agg)
239
+ return fastapi.responses.JSONResponse(series_rows(commits, points, on_branch))
240
+
241
+
242
+ def series_rows(commits, points, on_branch):
243
+ """Flat rows for Grafana, oldest commit first."""
244
+ by_sha = series_points(points)
245
+ result = []
246
+ for idx, commit in reversed(commits):
247
+ for (metric, labels_json), point in sorted(by_sha.get(commit.sha, {}).items()):
248
+ series_labels = json.loads(labels_json)
249
+ result.append(
250
+ {
251
+ "time": commit.time * 1000,
252
+ "idx": idx,
253
+ "sha": commit.sha,
254
+ "short": commit.sha[:8],
255
+ "subject": commit.subject,
256
+ "metric": metric,
257
+ "labels": series_labels,
258
+ "series": series_name(metric, series_labels),
259
+ "value": point.value,
260
+ "runs": point.runs,
261
+ "source": point.source,
262
+ "on_branch": commit.sha in on_branch,
263
+ }
264
+ )
265
+ return result
266
+
267
+
268
+ def series_points(points):
269
+ """{(sha, metric, labels): point} -> {sha: {(metric, labels): point}}"""
270
+ result = {}
271
+ for (sha, metric, labels_json), point in points.items():
272
+ result.setdefault(sha, {})[(metric, labels_json)] = point
273
+ return result
274
+
275
+
276
+ def parse_metrics(values):
277
+ names = [n.strip() for v in values for n in v.split(",") if n.strip()]
278
+ invalid = [n for n in names if not METRIC_RE.match(n)]
279
+ if invalid or not names:
280
+ raise fastapi.HTTPException(400, f"invalid metric names: {invalid}")
281
+ return names
282
+
283
+
284
+ def parse_label_filters(values):
285
+ filters = {}
286
+ for value in values:
287
+ key, sep, label_value = value.partition(":")
288
+ if not sep or not LABEL_KEY_RE.match(key):
289
+ raise fastapi.HTTPException(
290
+ 400, f"invalid label filter {value!r}, expected key:value"
291
+ )
292
+ filters.setdefault(key, []).append(label_value)
293
+ return filters
294
+
295
+
296
+ async def resolve(m, ref):
297
+ """Resolves a ref name or full SHA; 'default' means the remote's default branch.
298
+
299
+ Unknown refs trigger a (rate-limited) fetch before giving up.
300
+ """
301
+ if ref == "default":
302
+ ref = m.default_branch
303
+ try:
304
+ return await m.resolve(ref)
305
+ except mirror.UnknownRefError:
306
+ pass
307
+ try:
308
+ not_before = time.monotonic() - ON_DEMAND_FETCH_INTERVAL
309
+ await asyncio.wait_for(
310
+ asyncio.shield(m.fetch(not_before=not_before)), FETCH_TIMEOUT
311
+ )
312
+ except (TimeoutError, mirror.GitError) as e:
313
+ log.warning("fetch for unknown ref %s failed: %s", ref, e)
314
+ return await m.resolve(ref)
315
+
316
+
317
+ def series_name(metric, labels):
318
+ if not labels:
319
+ return metric
320
+ return f"{metric}{{{','.join(f'{k}={v}' for k, v in sorted(labels.items()))}}}"
321
+
322
+
323
+ @router.get("/api/v1/projects")
324
+ async def projects(state: StateDep, principal: PrincipalDep):
325
+ return [
326
+ {"name": name, "default_branch": p.mirror.default_branch}
327
+ for name, p in state.projects.items()
328
+ if principal.has_role(name, "reader")
329
+ ]
330
+
331
+
332
+ @router.get("/api/v1/projects/{project}/refs")
333
+ async def refs(
334
+ access: ReaderDep, active_since: str | None = None, match: str | None = None
335
+ ):
336
+ m = access.project.mirror
337
+ min_time = 0
338
+ if active_since is not None:
339
+ try:
340
+ min_time = time.time() - config.parse_duration(active_since)
341
+ except config.ConfigError as e:
342
+ raise fastapi.HTTPException(400, str(e)) from e
343
+ result = [
344
+ {
345
+ "name": r.name,
346
+ "default": r.name == m.default_branch,
347
+ "sha": r.sha,
348
+ "time": r.time * 1000,
349
+ }
350
+ for r in await m.refs()
351
+ if (r.time >= min_time or r.name == m.default_branch)
352
+ and (match is None or fnmatch.fnmatchcase(r.name, match))
353
+ ]
354
+ result.sort(key=lambda r: (not r["default"], -r["time"], r["name"]))
355
+ return result
356
+
357
+
358
+ @router.get("/api/v1/projects/{project}/metrics")
359
+ async def metrics(access: ReaderDep):
360
+ return await access.state.db.read(store.metrics, access.project.id)
361
+
362
+
363
+ @router.get("/api/v1/projects/{project}/compare")
364
+ async def compare( # noqa: PLR0913
365
+ access: ReaderDep,
366
+ head: str,
367
+ base: str = "default",
368
+ metrics: typing.Annotated[list[str], fastapi.Query(alias="metric")] = (),
369
+ labels: LabelsQuery = (),
370
+ agg: Aggregation = "median",
371
+ ):
372
+ if not mirror.SHA_RE.match(head):
373
+ raise fastapi.HTTPException(400, "head must be a full commit SHA")
374
+ metric_names = parse_metrics(metrics) if metrics else None
375
+ label_filters = parse_label_filters(labels)
376
+ m = access.project.mirror
377
+ await resolve(m, head)
378
+ tip = await resolve(m, base)
379
+ try:
380
+ fork = await m.merge_base(tip, head)
381
+ except mirror.NoMergeBaseError as e:
382
+ raise fastapi.HTTPException(409, str(e)) from e
383
+ walk = await m.first_parents(fork, access.project.config.compare_max_walk + 1)
384
+ rows = await access.state.db.read(
385
+ store.measurements,
386
+ access.project.id,
387
+ {head, tip, *walk},
388
+ metric_names,
389
+ label_filters,
390
+ )
391
+ points = series_points(store.aggregate(rows, agg))
392
+ head_points = points.get(head, {})
393
+ tip_points = points.get(tip, {})
394
+ baselines = {}
395
+ for distance, sha in enumerate(walk):
396
+ for key, point in points.get(sha, {}).items():
397
+ baselines.setdefault(key, (sha, distance, point))
398
+ results = [
399
+ comparison(key, head_points.get(key), baselines.get(key), tip_points.get(key))
400
+ for key in sorted(head_points.keys() | baselines.keys())
401
+ ]
402
+ return {
403
+ "head": {"sha": head, "short": head[:8]},
404
+ "base": {
405
+ "ref": m.default_branch if base == "default" else base,
406
+ "tip_sha": tip,
407
+ "fork_sha": fork,
408
+ },
409
+ "results": results,
410
+ }
411
+
412
+
413
+ def comparison(key, head, baseline, tip):
414
+ metric, labels_json = key
415
+ result = {
416
+ "metric": metric,
417
+ "labels": json.loads(labels_json),
418
+ "status": "both" if head and baseline else "head_only" if head else "base_only",
419
+ "head": head.value if head else None,
420
+ "head_runs": head.runs if head else None,
421
+ "baseline": None,
422
+ "baseline_sha": None,
423
+ "baseline_distance": None,
424
+ "base_tip": tip.value if tip else None,
425
+ "delta": None,
426
+ "delta_pct": None,
427
+ }
428
+ if baseline:
429
+ sha, distance, point = baseline
430
+ result.update(
431
+ baseline=point.value, baseline_sha=sha, baseline_distance=distance
432
+ )
433
+ if head and baseline:
434
+ delta = head.value - result["baseline"]
435
+ result["delta"] = delta
436
+ if result["baseline"]:
437
+ result["delta_pct"] = round(delta / abs(result["baseline"]) * 100, 4)
438
+ return result
439
+
440
+
441
+ @router.delete("/api/v1/projects/{project}/measurements")
442
+ async def delete_measurements(
443
+ access: AdminDep, run_id: str | None = None, metric: str | None = None
444
+ ):
445
+ if run_id is None and metric is None:
446
+ raise fastapi.HTTPException(400, "run_id or metric required")
447
+ deleted = await access.state.db.write(
448
+ store.delete_measurements, access.project.id, run_id, metric
449
+ )
450
+ log.info(
451
+ "deleted %d measurements",
452
+ deleted,
453
+ extra={
454
+ "project": access.project.config.name,
455
+ "run_id": run_id,
456
+ "metric": metric,
457
+ "submitted_by": access.principal.submitted_by,
458
+ "submitted_name": access.principal.display_name,
459
+ },
460
+ )
461
+ return {"deleted": deleted}
462
+
463
+
464
+ @router.get("/auth/cli/config")
465
+ async def cli_config(state: StateDep):
466
+ cli_auth = state.config.cli_auth
467
+ if cli_auth is None:
468
+ raise fastapi.HTTPException(404, "CLI login not configured")
469
+ return {
470
+ "authority": cli_auth.authority,
471
+ "client_id": cli_auth.client_id,
472
+ "scopes": list(cli_auth.scopes),
473
+ "redirect_uri": f"{state.config.server.public_url}/auth/cli/callback",
474
+ }
475
+
476
+
477
+ @router.get("/auth/cli/callback", response_class=fastapi.responses.HTMLResponse)
478
+ async def cli_callback(
479
+ state: StateDep,
480
+ oauth_state: typing.Annotated[str, fastapi.Query(alias="state")],
481
+ code: str | None = None,
482
+ error: str | None = None,
483
+ error_description: str | None = None,
484
+ ):
485
+ check_relay_state(oauth_state)
486
+ if code:
487
+ result = {"code": code}
488
+ message = "Login complete. Return to the terminal."
489
+ else:
490
+ result = {
491
+ "error": error or "unknown_error",
492
+ "error_description": error_description,
493
+ }
494
+ message = f"Login failed: {error} {error_description or ''}"
495
+ try:
496
+ state.relay.deliver(oauth_state, result)
497
+ except relay.RelayFullError as e:
498
+ raise fastapi.HTTPException(503, str(e)) from e
499
+ return f"<!doctype html><title>revtrend</title><p>{html.escape(message)}</p>"
500
+
501
+
502
+ @router.get("/auth/cli/poll")
503
+ async def cli_poll(
504
+ state: StateDep,
505
+ oauth_state: typing.Annotated[str, fastapi.Query(alias="state")],
506
+ max_wait: typing.Annotated[
507
+ float, fastapi.Query(alias="timeout", gt=0, le=MAX_POLL_TIMEOUT)
508
+ ] = MAX_POLL_TIMEOUT,
509
+ ):
510
+ check_relay_state(oauth_state)
511
+ try:
512
+ result = await state.relay.wait(oauth_state, max_wait)
513
+ except relay.RelayFullError as e:
514
+ raise fastapi.HTTPException(503, str(e)) from e
515
+ if result is None:
516
+ return fastapi.Response(status_code=204)
517
+ return result
518
+
519
+
520
+ def check_relay_state(value):
521
+ if not relay.STATE_RE.match(value):
522
+ raise fastapi.HTTPException(400, "invalid state")
revtrend/auth.py ADDED
@@ -0,0 +1,169 @@
1
+ import asyncio
2
+ import dataclasses
3
+ import logging
4
+ import time
5
+
6
+ import jwt
7
+
8
+ import revtrend.config as config
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ ROLE_LEVELS = {role: level for level, role in enumerate(config.ROLES, start=1)}
13
+ ALGORITHMS = [
14
+ "RS256",
15
+ "RS384",
16
+ "RS512",
17
+ "PS256",
18
+ "PS384",
19
+ "PS512",
20
+ "ES256",
21
+ "ES384",
22
+ "ES512",
23
+ "EdDSA",
24
+ ]
25
+ LEEWAY = 60
26
+ MAX_DISPLAY_NAME_LENGTH = 256
27
+ JWKS_MIN_REFRESH_INTERVAL = 60
28
+
29
+
30
+ class AuthError(Exception):
31
+ pass
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True)
35
+ class Principal:
36
+ issuer: str
37
+ subject: str
38
+ source: str
39
+ roles: dict[str, str]
40
+ display_name: str | None = None
41
+
42
+ @property
43
+ def submitted_by(self):
44
+ return f"{self.issuer}:{self.subject}"
45
+
46
+ def has_role(self, project, role):
47
+ granted = self.roles.get(project)
48
+ return granted is not None and ROLE_LEVELS[granted] >= ROLE_LEVELS[role]
49
+
50
+
51
+ class Authenticator:
52
+ """Validates bearer JWTs from the configured issuers and maps claims to roles."""
53
+
54
+ def __init__(self, issuers, http_client):
55
+ self._issuers = {i.issuer: i for i in issuers}
56
+ self._http = http_client
57
+ self._keys = {url: _IssuerKeys() for url in self._issuers}
58
+
59
+ async def authenticate(self, token):
60
+ try:
61
+ header = jwt.get_unverified_header(token)
62
+ unverified = jwt.decode(token, options={"verify_signature": False})
63
+ except jwt.PyJWTError as e:
64
+ raise AuthError(f"malformed token: {e}") from e
65
+ issuer = self._issuers.get(unverified.get("iss"))
66
+ if issuer is None:
67
+ raise AuthError("unknown issuer")
68
+ key = await self._key(issuer, header.get("kid"))
69
+ try:
70
+ claims = jwt.decode(
71
+ token,
72
+ key,
73
+ algorithms=ALGORITHMS,
74
+ audience=issuer.audience,
75
+ issuer=issuer.issuer,
76
+ leeway=LEEWAY,
77
+ options={"require": ["exp", "iss", "aud", "sub"]},
78
+ )
79
+ except jwt.PyJWTError as e:
80
+ raise AuthError(f"invalid token: {e}") from e
81
+ return principal(issuer, claims)
82
+
83
+ async def _key(self, issuer, kid):
84
+ keys = self._keys[issuer.issuer]
85
+ key = keys.find(kid)
86
+ if key is None:
87
+ await keys.refresh(self._http, issuer.issuer)
88
+ key = keys.find(kid)
89
+ if key is None:
90
+ raise AuthError(f"no signing key {kid!r} for issuer {issuer.name}")
91
+ return key
92
+
93
+
94
+ class _IssuerKeys:
95
+ def __init__(self):
96
+ self.jwks = None
97
+ self.fetched_at = None
98
+ self.lock = asyncio.Lock()
99
+
100
+ def find(self, kid):
101
+ if self.jwks is None:
102
+ return None
103
+ if kid is None:
104
+ return self.jwks.keys[0] if len(self.jwks.keys) == 1 else None
105
+ for key in self.jwks.keys:
106
+ if key.key_id == kid:
107
+ return key
108
+ return None
109
+
110
+ async def refresh(self, http, issuer_url):
111
+ async with self.lock:
112
+ now = time.monotonic()
113
+ if (
114
+ self.fetched_at is not None
115
+ and now - self.fetched_at < JWKS_MIN_REFRESH_INTERVAL
116
+ ):
117
+ return
118
+ self.fetched_at = now
119
+ discovery = await _get_json(
120
+ http, f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
121
+ )
122
+ self.jwks = jwt.PyJWKSet.from_dict(
123
+ await _get_json(http, discovery["jwks_uri"])
124
+ )
125
+ log.info("loaded %d signing keys for %s", len(self.jwks.keys), issuer_url)
126
+
127
+
128
+ async def _get_json(http, url):
129
+ response = await http.get(url)
130
+ response.raise_for_status()
131
+ return response.json()
132
+
133
+
134
+ def principal(issuer, claims):
135
+ roles = {}
136
+ source = None
137
+ for rule in issuer.rules:
138
+ if not matches(rule, claims.get(rule.claim)):
139
+ continue
140
+ for grant in rule.grants:
141
+ current = roles.get(grant.project)
142
+ if current is None or ROLE_LEVELS[grant.role] > ROLE_LEVELS[current]:
143
+ roles[grant.project] = grant.role
144
+ if source is None:
145
+ source = rule.source
146
+ return Principal(
147
+ issuer=issuer.name,
148
+ subject=claims["sub"],
149
+ source=source or issuer.source,
150
+ roles=roles,
151
+ display_name=display_name(issuer, claims),
152
+ )
153
+
154
+
155
+ def display_name(issuer, claims):
156
+ """First configured claim with a string value, for humans reading the audit trail."""
157
+ for claim in issuer.display_claims:
158
+ value = claims.get(claim)
159
+ if isinstance(value, str) and value:
160
+ return value[:MAX_DISPLAY_NAME_LENGTH]
161
+ return None
162
+
163
+
164
+ def matches(rule, value):
165
+ values = value if isinstance(value, list) else [value]
166
+ values = [v for v in values if isinstance(v, str)]
167
+ if rule.regex is not None:
168
+ return any(rule.regex.search(v) for v in values)
169
+ return rule.contains in values