imbi-plugin-github 2.41.0__py3-none-any.whl → 2.43.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.
@@ -65,6 +65,7 @@ from imbi.common.plugins.base import (
65
65
  WorkflowFile,
66
66
  )
67
67
  from imbi.common.plugins.errors import PluginAuthenticationFailed
68
+ from imbi.plugins.github import public
68
69
  from imbi.plugins.github._hosts import flavor_host, host_to_api_base
69
70
  from imbi.plugins.github._repos import (
70
71
  derive_owner_repo_from_links,
@@ -654,6 +655,10 @@ class GitHubDeployment(DeploymentCapability):
654
655
  per call.
655
656
  """
656
657
 
658
+ @classmethod
659
+ def public_reader(cls, url: str) -> public.PublicRepository | None:
660
+ return public.PublicRepository.for_url(url)
661
+
657
662
  def _resolve_host(self, ctx: PluginContext) -> str:
658
663
  return flavor_host(ctx.integration_options, 'github deployment')
659
664
 
@@ -25,6 +25,7 @@ from imbi.common.plugins.base import (
25
25
  PluginManifest,
26
26
  PluginOption,
27
27
  WebhookActionsCapability,
28
+ WebhookSignature,
28
29
  )
29
30
  from imbi.plugins.github.commits import (
30
31
  GitHubCommitSync,
@@ -39,6 +40,7 @@ from imbi.plugins.github.pull_requests import (
39
40
  GitHubPullRequestSync,
40
41
  sync_pull_requests_descriptor,
41
42
  )
43
+ from imbi.plugins.github.secret_provider import GitHubApp
42
44
 
43
45
 
44
46
  class GitHubWebhookActions(WebhookActionsCapability):
@@ -58,6 +60,13 @@ class GitHubWebhookActions(WebhookActionsCapability):
58
60
  sync_pull_requests_descriptor,
59
61
  ]
60
62
 
63
+ @classmethod
64
+ def signature(cls) -> WebhookSignature:
65
+ # The secret of the GitHub webhook is the Webhook.secret.
66
+ return WebhookSignature(
67
+ header='x-hub-signature-256', prefix='sha256=', secret='webhook'
68
+ )
69
+
61
70
 
62
71
  # Integration-level options, asked once per Integration.
63
72
  _OPTIONS: list[PluginOption] = [
@@ -418,4 +427,5 @@ class GitHubPlugin(Plugin):
418
427
  ),
419
428
  ],
420
429
  ops_log_templates=_OPS_LOG_TEMPLATES,
430
+ secret_providers=[GitHubApp],
421
431
  )
@@ -0,0 +1,84 @@
1
+ """Credential-free reads of a public ``github.com`` repository.
2
+
3
+ :meth:`GitHubDeployment.public_reader
4
+ <imbi.plugins.github.deployment.GitHubDeployment.public_reader>`
5
+ returns a :class:`PublicRepository`. The host uses it when no
6
+ Integration of the organization can read a repository, for example a
7
+ public Claude Code plugin marketplace.
8
+ """
9
+
10
+ import re
11
+ import typing
12
+ import urllib.parse
13
+
14
+ import httpx
15
+
16
+ from imbi.common.plugins import base
17
+
18
+ _GITHUB_REPO = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+')
19
+
20
+
21
+ class PublicRepository:
22
+ """The :class:`~imbi.common.plugins.base.RepositoryReader` of a public
23
+ ``github.com`` repository. It ignores ``ctx`` and ``credentials``."""
24
+
25
+ # ponytail: 60 unauthenticated requests an hour per API pod IP; use
26
+ # an org token if many public marketplaces sync often.
27
+ def __init__(self, owner: str, repo: str) -> None:
28
+ self.base = f'https://api.github.com/repos/{owner}/{repo}'
29
+
30
+ @classmethod
31
+ def for_url(cls, url: str) -> PublicRepository | None:
32
+ """Return the reader of a ``github.com`` repository URL, or
33
+ ``None`` for another host or a path that is not a repository."""
34
+ parts = urllib.parse.urlsplit(url)
35
+ path = parts.path.strip('/').removesuffix('.git')
36
+ if parts.hostname != 'github.com' or not _GITHUB_REPO.fullmatch(path):
37
+ return None
38
+ return cls(*path.split('/'))
39
+
40
+ async def _get(
41
+ self, path: str, accept: str = 'application/vnd.github+json'
42
+ ) -> httpx.Response:
43
+ # A renamed or moved repository answers 301 to /repositories/{id}.
44
+ async with httpx.AsyncClient(
45
+ timeout=30, follow_redirects=True
46
+ ) as client:
47
+ return await client.get(
48
+ f'{self.base}{path}', headers={'Accept': accept}
49
+ )
50
+
51
+ async def resolve_committish(
52
+ self, _ctx: object, _credentials: object, ref: str, /
53
+ ) -> base.Ref:
54
+ response = await self._get(f'/commits/{urllib.parse.quote(ref)}')
55
+ response.raise_for_status()
56
+ return base.Ref(name=ref, kind='branch', sha=response.json()['sha'])
57
+
58
+ async def list_refs(
59
+ self,
60
+ _ctx: object,
61
+ _credentials: object,
62
+ /,
63
+ kind: typing.Literal['default', 'branch', 'tag', 'all'] = 'all',
64
+ ) -> list[base.Ref]:
65
+ """Return the default branch only, whatever ``kind`` is."""
66
+ del kind
67
+ response = await self._get('')
68
+ response.raise_for_status()
69
+ branch = str(response.json()['default_branch'])
70
+ ref = await self.resolve_committish(None, None, branch)
71
+ return [ref.model_copy(update={'kind': 'default', 'is_default': True})]
72
+
73
+ async def get_file(
74
+ self, _ctx: object, _credentials: object, path: str, ref: str, /
75
+ ) -> str | None:
76
+ response = await self._get(
77
+ f'/contents/{urllib.parse.quote(path)}?'
78
+ f'{urllib.parse.urlencode({"ref": ref})}',
79
+ 'application/vnd.github.raw+json',
80
+ )
81
+ if response.status_code == 404:
82
+ return None
83
+ response.raise_for_status()
84
+ return response.text
@@ -0,0 +1,734 @@
1
+ """The ``github_app`` secret provider (ADR 0022, plan section 9.8).
2
+
3
+ The provider issues a GitHub App installation access token for each
4
+ resolution. The token is limited to the repositories of the task and to
5
+ the permissions of the purpose, and it lasts one hour at most. Release
6
+ revokes it with ``DELETE /installation/token``.
7
+
8
+ A binding without an ``installation_id`` finds the installation of the
9
+ repositories with the App JWT (``GET /repos/{owner}/{repo}/installation``
10
+ or ``GET /repositories/{id}/installation``), so one binding works in each
11
+ org that the App is installed in. The repositories of one token must be
12
+ in one installation.
13
+
14
+ The App private key is not in Imbi: the binding names the ``secret://``
15
+ reference of another binding (for example an ``aws_secrets_manager``
16
+ binding), and imbi-api reads the key through the broker for each call
17
+ (:meth:`SecretProviderHost.provider_key
18
+ <imbi.common.plugins.secrets.SecretProviderHost.provider_key>`). That key
19
+ binding must be marked as a provider key for the same host and App id,
20
+ and must list the requesting binding as a consumer.
21
+
22
+ The GitHub host comes from an allowlist, never from free tenant input:
23
+ ``github.com``, a GHEC tenant (``<tenant>.ghe.com``), or a GHES host that
24
+ the operator lists in ``IMBI_SECRETS_GITHUB_GHES_HOSTS``. A JWT signed
25
+ with an App key goes only to such a host.
26
+
27
+ The JWT and the token exchange come from :mod:`._app_auth`. Its
28
+ process-wide token cache is not used: each resolution gets its own
29
+ token, so that release can revoke it.
30
+
31
+ The lease id holds the token and the host it came from, because GitHub
32
+ revokes a token only with the token itself, and only at that host. The
33
+ broker keeps lease ids encrypted.
34
+ """
35
+
36
+ import collections.abc
37
+ import contextlib
38
+ import datetime
39
+ import ipaddress
40
+ import logging
41
+ import os
42
+ import re
43
+ import typing
44
+ import uuid
45
+
46
+ import httpx
47
+ import jwt
48
+ import orjson
49
+ import pydantic
50
+
51
+ from imbi.common import graph
52
+ from imbi.common.plugins import secrets
53
+ from imbi.plugins.github import _app_auth, _hosts, _repos
54
+ from imbi.plugins.github.deployment import (
55
+ _accept_header, # pyright: ignore[reportPrivateUsage]
56
+ _auth_headers, # pyright: ignore[reportPrivateUsage]
57
+ )
58
+
59
+ LOGGER = logging.getLogger(__name__)
60
+
61
+ #: The token permissions of each purpose. The App's own permissions are
62
+ #: the upper limit: GitHub refuses a request for more (422).
63
+ PURPOSE_PERMISSIONS: dict[str, dict[str, str]] = {
64
+ 'workspace_checkout': {'contents': 'read'},
65
+ 'marketplace_fetch': {'contents': 'read'},
66
+ 'github_write': {
67
+ 'contents': 'write',
68
+ 'pull_requests': 'write',
69
+ 'issues': 'write',
70
+ },
71
+ }
72
+
73
+ #: The binding test asks for the smallest permission set.
74
+ _TEST_PERMISSIONS = {'metadata': 'read'}
75
+
76
+ _TIMEOUT_SECONDS = 10.0
77
+ #: When GitHub gives no expiry, assume less than its one hour.
78
+ _DEFAULT_TTL = datetime.timedelta(minutes=55)
79
+ _REPOSITORY = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+')
80
+ _GHEC_TENANT = re.compile(r'[a-z0-9][a-z0-9-]*\.ghe\.com')
81
+ _REPOSITORY_ID = re.compile(r'[0-9]{1,20}')
82
+ #: The plugin of the Integrations that verify a project's repository.
83
+ GITHUB_PLUGIN = 'github'
84
+ #: The verified repository ids of a project, one row for each
85
+ #: ``EXISTS_IN`` edge to a GitHub Integration. An edge whose
86
+ #: identifier was changed by hand after verification does not count.
87
+ _VERIFIED_REPOSITORIES: typing.LiteralString = (
88
+ 'MATCH (:Project {{id: {project_id}}})-[ei:EXISTS_IN]->'
89
+ '(i:Integration {{plugin: {plugin}}}) '
90
+ 'WHERE ei.verified_identifier = ei.identifier '
91
+ 'RETURN i.options AS options, '
92
+ 'ei.verified_identifier AS repository_id'
93
+ )
94
+ #: The operator's GHES hosts, comma separated. Never tenant input.
95
+ GHES_HOSTS_ENV = 'IMBI_SECRETS_GITHUB_GHES_HOSTS'
96
+
97
+ #: GitHub status codes, by safe reason code.
98
+ _HTTP_REASONS = {
99
+ 401: 'app_authentication_failed',
100
+ 403: 'access_denied',
101
+ 404: 'installation_not_found',
102
+ 422: 'permission_or_repository_not_granted',
103
+ }
104
+
105
+
106
+ def _internal_address(host: str) -> bool:
107
+ """True for a private, loopback, link-local, or other non-public IP."""
108
+ try:
109
+ address = ipaddress.ip_address(host.strip('[]'))
110
+ except ValueError:
111
+ return False
112
+ return not address.is_global
113
+
114
+
115
+ def ghes_hosts() -> frozenset[str]:
116
+ """The GHES hosts of :data:`GHES_HOSTS_ENV`, without IP addresses
117
+ that are not public."""
118
+ hosts: set[str] = set()
119
+ for raw in os.environ.get(GHES_HOSTS_ENV, '').split(','):
120
+ if not raw.strip():
121
+ continue
122
+ try:
123
+ host = _hosts.normalize_host(raw, GHES_HOSTS_ENV).lower()
124
+ except ValueError:
125
+ LOGGER.warning('%s: a value is not a host', GHES_HOSTS_ENV)
126
+ continue
127
+ if _internal_address(host):
128
+ LOGGER.warning(
129
+ '%s: %s is not a public address', GHES_HOSTS_ENV, host
130
+ )
131
+ continue
132
+ hosts.add(host)
133
+ return frozenset(hosts)
134
+
135
+
136
+ def allowed_host(host: str) -> bool:
137
+ """True for github.com, a GHEC tenant, or an operator GHES host."""
138
+ return (
139
+ host == 'github.com'
140
+ or _GHEC_TENANT.fullmatch(host) is not None
141
+ or host in ghes_hosts()
142
+ )
143
+
144
+
145
+ def check_host(value: str) -> str:
146
+ """Return the normalized host, or raise ``ValueError``."""
147
+ host = _hosts.normalize_host(value, 'host').lower()
148
+ if not allowed_host(host):
149
+ raise ValueError(
150
+ 'use github.com, a <tenant>.ghe.com host, or a GHES host in '
151
+ + GHES_HOSTS_ENV
152
+ )
153
+ return host
154
+
155
+
156
+ class _Config(pydantic.BaseModel):
157
+ """One provider for each GitHub host: github.com, a GHEC tenant
158
+ (``<tenant>.ghe.com``), or a GHES appliance."""
159
+
160
+ model_config = pydantic.ConfigDict(extra='forbid')
161
+
162
+ host: str = 'github.com'
163
+
164
+ @pydantic.field_validator('host')
165
+ @classmethod
166
+ def _host(cls, value: str) -> str:
167
+ return check_host(value)
168
+
169
+
170
+ class _Locator(pydantic.BaseModel):
171
+ model_config = pydantic.ConfigDict(extra='forbid')
172
+
173
+ app_id: str = pydantic.Field(pattern=r'^[0-9]{1,20}$')
174
+ #: ``None``: find the installation of each request's repositories.
175
+ installation_id: str | None = pydantic.Field(
176
+ default=None, pattern=r'^[0-9]{1,20}$'
177
+ )
178
+ #: The ``secret://`` reference of the App private key (PEM, or
179
+ #: base64 PEM), in a binding that is not ``dynamic``.
180
+ private_key: str
181
+ #: ``owner/name`` repositories for ``marketplace_fetch``: the
182
+ #: marketplaces and their external plugins. Other purposes use the
183
+ #: repositories of the task's project.
184
+ repositories: list[str] = pydantic.Field(default_factory=list[str])
185
+
186
+ @pydantic.field_validator('private_key')
187
+ @classmethod
188
+ def _reference(cls, value: str) -> str:
189
+ try:
190
+ return str(secrets.parse_reference(value))
191
+ except secrets.BrokerError:
192
+ raise ValueError('not a valid secret:// reference') from None
193
+
194
+ @pydantic.field_validator('repositories')
195
+ @classmethod
196
+ def _repositories(cls, value: list[str]) -> list[str]:
197
+ if not all(_REPOSITORY.fullmatch(name) for name in value):
198
+ raise ValueError('use the owner/name form')
199
+ return value
200
+
201
+
202
+ def _host(config: dict[str, typing.Any]) -> str:
203
+ return str(config.get('host', 'github.com'))
204
+
205
+
206
+ def _api_base(host: str) -> str:
207
+ """The API base of an allowed host. The allowlist is checked again
208
+ at each call, because the operator list can change."""
209
+ if not allowed_host(host):
210
+ raise secrets.BrokerError('secret_access_denied', 'host_not_allowed')
211
+ return _hosts.host_to_api_base(host)
212
+
213
+
214
+ def _lease(host: str, token: str) -> str:
215
+ return orjson.dumps({'host': host, 'token': token}).decode()
216
+
217
+
218
+ @contextlib.contextmanager
219
+ def _github_errors(code: str) -> collections.abc.Generator[None]:
220
+ """Translate a GitHub or key failure into a :class:`BrokerError`.
221
+
222
+ Only the status code and the exception type are read. A response
223
+ body, a header, or a key never goes into the error or the log.
224
+ """
225
+ try:
226
+ yield
227
+ except secrets.BrokerError:
228
+ raise
229
+ except httpx.HTTPStatusError as err:
230
+ status = err.response.status_code
231
+ LOGGER.warning('GitHub App call failed: HTTP %d', status)
232
+ if status == 429 or status >= 500:
233
+ raise secrets.BrokerError(
234
+ 'secret_provider_unavailable', 'throttled'
235
+ ) from None
236
+ raise secrets.BrokerError(
237
+ code, _HTTP_REASONS.get(status, 'provider_error')
238
+ ) from None
239
+ except httpx.TransportError as err:
240
+ LOGGER.warning('GitHub App call failed: %s', type(err).__name__)
241
+ raise secrets.BrokerError(
242
+ 'secret_provider_unavailable', 'unreachable'
243
+ ) from None
244
+ except (ValueError, TypeError, jwt.PyJWTError) as err:
245
+ LOGGER.warning('GitHub App key not usable: %s', type(err).__name__)
246
+ raise secrets.BrokerError(code, 'invalid_private_key') from None
247
+
248
+
249
+ def _options(raw: object) -> dict[str, typing.Any]:
250
+ """Decode an Integration ``options`` value, as AGE returns it."""
251
+ parsed = graph.parse_agtype(raw)
252
+ if isinstance(parsed, str):
253
+ try:
254
+ parsed = orjson.loads(parsed)
255
+ except orjson.JSONDecodeError:
256
+ return {}
257
+ if isinstance(parsed, dict):
258
+ return typing.cast(dict[str, typing.Any], parsed)
259
+ return {}
260
+
261
+
262
+ def _expiry(value: object) -> datetime.datetime:
263
+ if isinstance(value, str):
264
+ try:
265
+ parsed = datetime.datetime.fromisoformat(value)
266
+ except ValueError:
267
+ pass
268
+ else:
269
+ if parsed.tzinfo is None:
270
+ parsed = parsed.replace(tzinfo=datetime.UTC)
271
+ return parsed
272
+ return datetime.datetime.now(datetime.UTC) + _DEFAULT_TTL
273
+
274
+
275
+ class GitHubApp(secrets.SecretProvider):
276
+ """GitHub App installation access tokens, one for each resolution."""
277
+
278
+ type = 'github_app'
279
+ label = 'GitHub App'
280
+ capabilities = frozenset[secrets.SecretCapability](
281
+ {'dynamic_issuance', 'lease_revocation', 'health_check'}
282
+ )
283
+
284
+ def validate_config(
285
+ self, config: dict[str, typing.Any]
286
+ ) -> dict[str, typing.Any]:
287
+ return secrets.validate_model(_Config, config)
288
+
289
+ def validate_locator(
290
+ self,
291
+ locator: dict[str, typing.Any],
292
+ credential_class: secrets.CredentialClass,
293
+ ) -> dict[str, typing.Any]:
294
+ if credential_class != 'dynamic':
295
+ raise secrets.InvalidConfiguration(
296
+ 'A github_app binding is dynamic'
297
+ )
298
+ return secrets.validate_model(_Locator, locator)
299
+
300
+ def check_key_host(self, host: str) -> str:
301
+ return check_host(host)
302
+
303
+ def repository(
304
+ self, config: dict[str, typing.Any], url: str
305
+ ) -> tuple[str, str] | None:
306
+ host = _host(config)
307
+ found = _repos.parse_owner_repo(url, host)
308
+ return None if found is None else (host, f'{found[0]}/{found[1]}')
309
+
310
+ async def project_repository_ids(
311
+ self, host: str, project_id: str | None
312
+ ) -> list[int]:
313
+ """Return the verified GitHub repository ids of a project on ``host``.
314
+
315
+ Only the ``verified_identifier`` of an ``EXISTS_IN`` edge to a GitHub
316
+ Integration for ``host`` counts. The GitHub plugin writes it from the
317
+ GitHub API (the lifecycle plugin and the doctor repair). A project
318
+ link, or an edge identifier that a user edits, gives no access.
319
+
320
+ Raises:
321
+ BrokerError: ``project_repository_unverified`` when the project
322
+ has no verified repository on ``host``.
323
+
324
+ """
325
+ if project_id is None:
326
+ return []
327
+ records = await self.host.graph().execute(
328
+ _VERIFIED_REPOSITORIES,
329
+ {'project_id': project_id, 'plugin': GITHUB_PLUGIN},
330
+ ['options', 'repository_id'],
331
+ )
332
+ found: set[int] = set()
333
+ for record in records:
334
+ value = str(graph.parse_agtype(record.get('repository_id')) or '')
335
+ if _REPOSITORY_ID.fullmatch(value) is None:
336
+ continue
337
+ try:
338
+ integration_host = _hosts.flavor_host(
339
+ _options(record.get('options')), 'github_app'
340
+ )
341
+ except ValueError:
342
+ continue
343
+ if integration_host.lower() == host:
344
+ found.add(int(value))
345
+ if not found:
346
+ raise secrets.BrokerError(
347
+ 'secret_access_denied', 'project_repository_unverified'
348
+ )
349
+ return sorted(found)
350
+
351
+ async def check_locator(
352
+ self,
353
+ config: dict[str, typing.Any],
354
+ locator: dict[str, typing.Any],
355
+ organization_id: str,
356
+ ) -> None:
357
+ """The key binding must be a provider key for this host and App."""
358
+ try:
359
+ await self.host.check_provider_key(
360
+ organization_id,
361
+ locator['private_key'],
362
+ host=_host(config),
363
+ app_id=locator['app_id'],
364
+ )
365
+ except secrets.BrokerError as err:
366
+ raise secrets.InvalidConfiguration(
367
+ 'private_key must name a provider key binding for host '
368
+ f'{_host(config)} and App {locator["app_id"]}',
369
+ code=err.reason,
370
+ ) from None
371
+
372
+ @contextlib.asynccontextmanager
373
+ async def _app_client(
374
+ self,
375
+ config: dict[str, typing.Any],
376
+ locator: dict[str, typing.Any],
377
+ organization_id: str,
378
+ *,
379
+ consumer: uuid.UUID | None,
380
+ task_id: uuid.UUID | None,
381
+ ) -> collections.abc.AsyncGenerator[httpx.AsyncClient]:
382
+ """A client that authenticates as the App (a JWT)."""
383
+ host = _host(config)
384
+ base = _api_base(host)
385
+ material = await self.host.provider_key(
386
+ organization_id,
387
+ locator['private_key'],
388
+ consumer=consumer,
389
+ host=host,
390
+ app_id=locator['app_id'],
391
+ task_id=task_id,
392
+ )
393
+ key = material.value.decode()
394
+ app_jwt = _app_auth._app_jwt( # pyright: ignore[reportPrivateUsage]
395
+ locator['app_id'], key
396
+ )
397
+ async with httpx.AsyncClient(
398
+ base_url=base,
399
+ headers=_auth_headers(app_jwt),
400
+ timeout=_TIMEOUT_SECONDS,
401
+ ) as client:
402
+ yield client
403
+
404
+ async def _installation(
405
+ self, client: httpx.AsyncClient, installation_id: str
406
+ ) -> dict[str, typing.Any]:
407
+ response = await client.get(f'/app/installations/{installation_id}')
408
+ response.raise_for_status()
409
+ return typing.cast(dict[str, typing.Any], response.json())
410
+
411
+ async def _installation_for(
412
+ self,
413
+ client: httpx.AsyncClient,
414
+ repositories: list[str],
415
+ repository_ids: list[int] | None,
416
+ ) -> dict[str, typing.Any]:
417
+ """The one installation of the repositories.
418
+
419
+ Raises:
420
+ BrokerError: ``repositories_span_installations`` when two
421
+ repositories are in different installations.
422
+
423
+ """
424
+ paths = [f'/repos/{name}/installation' for name in repositories] + [
425
+ f'/repositories/{rid}/installation' for rid in repository_ids or []
426
+ ]
427
+ # ponytail: one lookup for each repository and resolution; cache
428
+ # by repository when this is too many GitHub calls.
429
+ found: dict[str, dict[str, typing.Any]] = {}
430
+ for path in paths:
431
+ response = await client.get(path)
432
+ response.raise_for_status()
433
+ installation = typing.cast(dict[str, typing.Any], response.json())
434
+ found[str(installation.get('id'))] = installation
435
+ if len(found) != 1:
436
+ raise secrets.BrokerError(
437
+ 'secret_access_denied', 'repositories_span_installations'
438
+ )
439
+ return next(iter(found.values()))
440
+
441
+ async def _mint(
442
+ self,
443
+ client: httpx.AsyncClient,
444
+ installation_id: str,
445
+ permissions: dict[str, str],
446
+ repositories: list[str] | None = None,
447
+ repository_ids: list[int] | None = None,
448
+ ) -> tuple[str, datetime.datetime]:
449
+ token, expires_at = await _app_auth._mint( # pyright: ignore[reportPrivateUsage]
450
+ client,
451
+ installation_id,
452
+ _app_auth.freeze_scope(permissions),
453
+ repositories,
454
+ repository_ids,
455
+ )
456
+ return token, _expiry(expires_at)
457
+
458
+ async def health(
459
+ self, config: dict[str, typing.Any], organization_id: str
460
+ ) -> secrets.Health:
461
+ """Check that the GitHub API answers. No credentials are used."""
462
+ try:
463
+ async with httpx.AsyncClient(
464
+ base_url=_api_base(_host(config)),
465
+ headers=_accept_header(),
466
+ timeout=_TIMEOUT_SECONDS,
467
+ ) as client:
468
+ response = await client.get('/meta')
469
+ except secrets.BrokerError as err:
470
+ return secrets.Health('unhealthy', err.reason)
471
+ except httpx.TransportError:
472
+ return secrets.Health('unhealthy', 'unreachable')
473
+ if response.status_code != 200:
474
+ return secrets.Health('unhealthy', 'provider_error')
475
+ return secrets.Health('healthy', identity={'host': _host(config)})
476
+
477
+ async def validate_binding(
478
+ self,
479
+ config: dict[str, typing.Any],
480
+ locator: dict[str, typing.Any],
481
+ organization_id: str,
482
+ binding_id: uuid.UUID | None = None,
483
+ ) -> dict[str, typing.Any]:
484
+ """Mint a token and return its metadata only, never the token.
485
+
486
+ The token has only ``metadata: read`` and is revoked before this
487
+ returns. Without an ``installation_id``, list the installations
488
+ of the App instead; no token is minted.
489
+ """
490
+ if not locator.get('installation_id'):
491
+ return await self._installations(
492
+ config, locator, organization_id, binding_id
493
+ )
494
+ with _github_errors('secret_materialization_failed'):
495
+ async with self._app_client(
496
+ config,
497
+ locator,
498
+ organization_id,
499
+ consumer=binding_id,
500
+ task_id=None,
501
+ ) as client:
502
+ installation = await self._installation(
503
+ client, locator['installation_id']
504
+ )
505
+ token, expires_at = await self._mint(
506
+ client, locator['installation_id'], _TEST_PERMISSIONS
507
+ )
508
+ try:
509
+ async with httpx.AsyncClient(
510
+ base_url=_api_base(_host(config)),
511
+ headers=_auth_headers(token),
512
+ timeout=_TIMEOUT_SECONDS,
513
+ ) as client:
514
+ response = await client.get(
515
+ '/installation/repositories',
516
+ params={'per_page': 100},
517
+ )
518
+ response.raise_for_status()
519
+ listed = typing.cast(
520
+ dict[str, typing.Any], response.json()
521
+ )
522
+ finally:
523
+ await _revoke_token(_host(config), token)
524
+ return {
525
+ 'app_slug': installation.get('app_slug'),
526
+ 'installation_id': locator['installation_id'],
527
+ 'account': _account(installation),
528
+ 'permissions': installation.get('permissions') or {},
529
+ 'repository_selection': installation.get('repository_selection'),
530
+ 'repository_count': listed.get('total_count'),
531
+ 'repositories': [
532
+ repo['full_name'] for repo in listed.get('repositories', [])
533
+ ],
534
+ 'token_expires_at': expires_at.isoformat(),
535
+ }
536
+
537
+ async def _installations(
538
+ self,
539
+ config: dict[str, typing.Any],
540
+ locator: dict[str, typing.Any],
541
+ organization_id: str,
542
+ binding_id: uuid.UUID | None,
543
+ ) -> dict[str, typing.Any]:
544
+ """The App and the accounts it is installed on."""
545
+ with _github_errors('secret_materialization_failed'):
546
+ async with self._app_client(
547
+ config,
548
+ locator,
549
+ organization_id,
550
+ consumer=binding_id,
551
+ task_id=None,
552
+ ) as client:
553
+ app = await client.get('/app')
554
+ app.raise_for_status()
555
+ installations: list[dict[str, typing.Any]] = []
556
+ page = 1
557
+ while True:
558
+ listed = await client.get(
559
+ '/app/installations',
560
+ params={'per_page': 100, 'page': page},
561
+ )
562
+ listed.raise_for_status()
563
+ batch = typing.cast(
564
+ list[dict[str, typing.Any]], listed.json()
565
+ )
566
+ installations.extend(batch)
567
+ if len(batch) < 100:
568
+ break
569
+ page += 1
570
+ return {
571
+ 'app_slug': typing.cast(dict[str, typing.Any], app.json()).get(
572
+ 'slug'
573
+ ),
574
+ 'installation_id': None,
575
+ 'installations': [
576
+ {'id': str(i.get('id')), 'account': _account(i)}
577
+ for i in installations
578
+ ],
579
+ }
580
+
581
+ async def resolve(
582
+ self,
583
+ config: dict[str, typing.Any],
584
+ locator: dict[str, typing.Any],
585
+ field: str | None,
586
+ context: secrets.ResolveContext,
587
+ ) -> secrets.Material:
588
+ """Mint a token for the task's repositories and the purpose.
589
+
590
+ ``marketplace_fetch`` uses the repository that the runner asks
591
+ for, which must be in the ``repositories`` of the binding; with
592
+ none, all of them. The other purposes use the verified repository
593
+ ids of the task's project (:meth:`project_repository_ids`).
594
+
595
+ Raises:
596
+ BrokerError: The purpose is unknown, the task has no
597
+ verified repository, a repository is outside the
598
+ installation, or GitHub refused the request.
599
+
600
+ """
601
+ if field is not None:
602
+ raise secrets.BrokerError('secret_field_not_allowed')
603
+ permissions = PURPOSE_PERMISSIONS.get(context.purpose)
604
+ if permissions is None:
605
+ raise secrets.BrokerError(
606
+ 'secret_access_denied', 'purpose_not_supported'
607
+ )
608
+ repositories: list[str] = []
609
+ repository_ids: list[int] | None = None
610
+ if context.purpose == 'marketplace_fetch':
611
+ repositories = list(locator.get('repositories', []))
612
+ if context.repository is not None:
613
+ if context.repository.lower() not in {
614
+ name.lower() for name in repositories
615
+ }:
616
+ raise secrets.BrokerError(
617
+ 'secret_access_denied', 'repository_not_allowed'
618
+ )
619
+ repositories = [context.repository]
620
+ else:
621
+ repository_ids = await self.project_repository_ids(
622
+ _host(config), context.project_id
623
+ )
624
+ # An empty list never reaches GitHub: no repositories would give
625
+ # a token for every repository of the installation.
626
+ if not repositories and not repository_ids:
627
+ raise secrets.BrokerError(
628
+ 'secret_access_denied', 'no_repositories'
629
+ )
630
+ with _github_errors('secret_materialization_failed'):
631
+ async with self._app_client(
632
+ config,
633
+ locator,
634
+ context.organization_id,
635
+ consumer=context.binding_id,
636
+ task_id=context.task_id,
637
+ ) as client:
638
+ installation_id = locator.get('installation_id')
639
+ installation = None
640
+ if not installation_id:
641
+ installation = await self._installation_for(
642
+ client, repositories, repository_ids
643
+ )
644
+ installation_id = str(installation['id'])
645
+ elif repositories:
646
+ installation = await self._installation(
647
+ client, installation_id
648
+ )
649
+ names = None
650
+ if installation is not None and repositories:
651
+ names = _names(installation, repositories)
652
+ token, expires_at = await self._mint(
653
+ client,
654
+ installation_id,
655
+ permissions,
656
+ names,
657
+ repository_ids,
658
+ )
659
+ return secrets.Material(
660
+ token.encode(),
661
+ credential_expires_at=expires_at,
662
+ lease_id=_lease(_host(config), token),
663
+ revocable=True,
664
+ )
665
+
666
+ async def revoke(
667
+ self, config: dict[str, typing.Any], lease_id: str
668
+ ) -> None:
669
+ """Revoke a token at the host that issued it, from the lease.
670
+
671
+ The provider configuration is not used: its host can change
672
+ after the token was minted.
673
+ """
674
+ try:
675
+ lease = typing.cast(dict[str, str], orjson.loads(lease_id))
676
+ host, token = lease['host'], lease['token']
677
+ except (orjson.JSONDecodeError, KeyError, TypeError):
678
+ raise secrets.BrokerError(
679
+ 'provider_revocation_failed', 'invalid_lease'
680
+ ) from None
681
+ await _revoke_token(host, token)
682
+
683
+
684
+ async def _revoke_token(host: str, token: str) -> None:
685
+ """Revoke a token. A 401 means it no longer works: success."""
686
+ base = _api_base(host)
687
+ try:
688
+ async with httpx.AsyncClient(
689
+ base_url=base,
690
+ headers=_auth_headers(token),
691
+ timeout=_TIMEOUT_SECONDS,
692
+ ) as client:
693
+ response = await client.delete('/installation/token')
694
+ except httpx.TransportError as err:
695
+ LOGGER.warning('GitHub token revocation: %s', type(err).__name__)
696
+ raise secrets.BrokerError(
697
+ 'provider_revocation_failed', 'unreachable'
698
+ ) from None
699
+ if response.status_code not in (204, 401):
700
+ LOGGER.warning(
701
+ 'GitHub token revocation: HTTP %d', response.status_code
702
+ )
703
+ raise secrets.BrokerError(
704
+ 'provider_revocation_failed', 'provider_error'
705
+ )
706
+
707
+
708
+ def _names(
709
+ installation: dict[str, typing.Any], repositories: list[str]
710
+ ) -> list[str]:
711
+ """Return the repository names for the token request.
712
+
713
+ GitHub takes names without the owner, inside the installation's
714
+ account. A repository of another owner is refused here, because its
715
+ name could match a different repository of that account.
716
+ """
717
+ account = _account(installation)
718
+ names: list[str] = []
719
+ for repository in repositories:
720
+ owner, _, name = repository.partition('/')
721
+ if not account or owner.lower() != account.lower():
722
+ raise secrets.BrokerError(
723
+ 'secret_access_denied', 'repository_outside_installation'
724
+ )
725
+ names.append(name)
726
+ return names
727
+
728
+
729
+ def _account(installation: dict[str, typing.Any]) -> str:
730
+ """The login of the account that the App is installed on."""
731
+ account = typing.cast(
732
+ dict[str, typing.Any], installation.get('account') or {}
733
+ )
734
+ return str(account.get('login') or '')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.41.0
3
+ Version: 2.43.0
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License-Expression: BSD-3-Clause
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
14
  Requires-Dist: httpx>=0.27
15
- Requires-Dist: imbi-common[databases]==2.41.0
15
+ Requires-Dist: imbi-common[databases]==2.43.0
16
16
  Requires-Dist: pydantic>=2
17
17
  Requires-Dist: pyjwt[crypto]>=2.8
18
18
  Description-Content-Type: text/markdown
@@ -4,13 +4,15 @@ imbi/plugins/github/_app_auth.py,sha256=qr3azBExZSI0vDIIKMKV8nSllJNKCJLKBXV86lIp
4
4
  imbi/plugins/github/_hosts.py,sha256=sYuE3f7934125VZ6GGTqm8t3sVa3m0DNt3jL9Hb8nM0,4014
5
5
  imbi/plugins/github/_repos.py,sha256=BeSwrpzQnFBtlUGW-eP7v6_gAcSaSkqObq-vhj5wcYA,6575
6
6
  imbi/plugins/github/commits.py,sha256=wHs_NUl4cmhfvD3WrnOCmqaaJS-Eku-E-lOMh6Cb8IU,71882
7
- imbi/plugins/github/deployment.py,sha256=pRlYWWIDr1bFkjX1CMWiI_cfRsk4raMNrligGoMsCbE,111061
7
+ imbi/plugins/github/deployment.py,sha256=bC5qMvbO-N0f5DTNo7ObyZIUtN4cl8uyjHgpp57W5_Y,111242
8
8
  imbi/plugins/github/doctor.py,sha256=sIps9YyDFLva7JHyKJU0IkKSW8tiI-PArC0s1ytxv3w,33177
9
9
  imbi/plugins/github/identity.py,sha256=9t_e-_jUbQ1iL0Vb6vaK2d7bT1ki6pw9aWwc4ics6gI,10354
10
10
  imbi/plugins/github/lifecycle.py,sha256=KJSzB36zFqetQBY5HNXLsRGRoIwGhrBJ-I1mA8G9H_Q,37711
11
- imbi/plugins/github/plugin.py,sha256=JFSHpkU6u-A6bcpAdv0H6zS_Qm7r_W0Poz1XZ6TuNyg,15615
11
+ imbi/plugins/github/plugin.py,sha256=KmAu1lrGX2tkaNqTd3GV9DwLaPd78ZrODGi6jUt2Z0c,15981
12
+ imbi/plugins/github/public.py,sha256=GZnTLbj_Gl5sHWuRqFuajnoO2xPudNQumAt4h_uJpYw,3114
12
13
  imbi/plugins/github/pull_requests.py,sha256=FiHbjdGqghJ2Q1--d15XfOtpUe_KZE7hYRb8JLQ5U1I,13510
13
14
  imbi/plugins/github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- imbi_plugin_github-2.41.0.dist-info/METADATA,sha256=HNOimCh3kasWhCiywedz4iAi7nTv4NyWqFL3QrGez1k,7618
15
- imbi_plugin_github-2.41.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
- imbi_plugin_github-2.41.0.dist-info/RECORD,,
15
+ imbi/plugins/github/secret_provider.py,sha256=83BQwbG6AuIlyCWAskkN5hrVbRc9X5UqQJl5qt7Pij0,27317
16
+ imbi_plugin_github-2.43.0.dist-info/METADATA,sha256=IJBROVQR2tagTrCI0PddNXFWxlbGmcPZSkieQLVPQbQ,7618
17
+ imbi_plugin_github-2.43.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
18
+ imbi_plugin_github-2.43.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any