c2cciutils 1.7.0.dev294__py3-none-any.whl → 1.7.0.dev297__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.

Potentially problematic release.


This version of c2cciutils might be problematic. Click here for more details.

c2cciutils/lib/oidc.py ADDED
@@ -0,0 +1,185 @@
1
+ """
2
+ Manage OpenID Connect (OIDC) token exchange for external services.
3
+
4
+ Inspired by
5
+ https://github.com/pypa/gh-action-pypi-publish/blob/unstable/v1/oidc-exchange.py
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import os
11
+ import sys
12
+ from typing import NoReturn
13
+
14
+ import id as oidc_id # pylint: disable=import-error
15
+ import requests
16
+
17
+
18
+ class _OidcError(Exception):
19
+ pass
20
+
21
+
22
+ def _fatal(message: str) -> NoReturn:
23
+ # HACK: GitHub Actions' annotations don't work across multiple lines naively;
24
+ # translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
25
+ # See: https://github.com/actions/toolkit/issues/193
26
+ message = message.replace("\n", "%0A")
27
+ print(f"::error::Trusted publishing exchange failure: {message}", file=sys.stderr)
28
+ raise _OidcError(message)
29
+
30
+
31
+ def _debug(message: str) -> None:
32
+ print(f"::debug::{message.title()}", file=sys.stderr)
33
+
34
+
35
+ def _render_claims(token: str) -> str:
36
+ _, payload, _ = token.split(".", 2)
37
+
38
+ # urlsafe_b64decode needs padding; JWT payloads don't contain any.
39
+ payload += "=" * (4 - (len(payload) % 4))
40
+ claims = json.loads(base64.urlsafe_b64decode(payload))
41
+
42
+ return f"""
43
+ The claims rendered below are **for debugging purposes only**. You should **not**
44
+ use them to configure a trusted publisher unless they already match your expectations.
45
+
46
+ If a claim is not present in the claim set, then it is rendered as `MISSING`.
47
+
48
+ * `sub`: `{claims.get('sub', 'MISSING')}`
49
+ * `repository`: `{claims.get('repository', 'MISSING')}`
50
+ * `repository_owner`: `{claims.get('repository_owner', 'MISSING')}`
51
+ * `repository_owner_id`: `{claims.get('repository_owner_id', 'MISSING')}`
52
+ * `job_workflow_ref`: `{claims.get('job_workflow_ref', 'MISSING')}`
53
+ * `ref`: `{claims.get('ref')}`
54
+
55
+ See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help.
56
+ """
57
+
58
+
59
+ def _get_token(hostname: str) -> str:
60
+ # Indices are expected to support `https://{hostname}/_/oidc/audience`,
61
+ # which tells OIDC exchange clients which audience to use.
62
+ audience_resp = requests.get(f"https://{hostname}/_/oidc/audience", timeout=5)
63
+ audience_resp.raise_for_status()
64
+
65
+ _debug(f"selected trusted publishing exchange endpoint: https://{hostname}/_/oidc/mint-token")
66
+
67
+ try:
68
+ oidc_token = oidc_id.detect_credential(audience=audience_resp.json()["audience"])
69
+ except oidc_id.IdentityError as identity_error:
70
+ _fatal(
71
+ f"""
72
+ OpenID Connect token retrieval failed: {identity_error}
73
+
74
+ This generally indicates a workflow configuration error, such as insufficient
75
+ permissions. Make sure that your workflow has `id-token: write` configured
76
+ at the job level, e.g.:
77
+
78
+ ```yaml
79
+ permissions:
80
+ id-token: write
81
+ ```
82
+
83
+ Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings.
84
+ """
85
+ )
86
+
87
+ # Now we can do the actual token exchange.
88
+ mint_token_resp = requests.post(
89
+ f"https://{hostname}/_/oidc/mint-token",
90
+ json={"token": oidc_token},
91
+ timeout=5,
92
+ )
93
+
94
+ try:
95
+ mint_token_payload = mint_token_resp.json()
96
+ except requests.JSONDecodeError:
97
+ # Token exchange failure normally produces a JSON error response, but
98
+ # we might have hit a server error instead.
99
+ _fatal(
100
+ f"""
101
+ Token request failed: the index produced an unexpected
102
+ {mint_token_resp.status_code} response.
103
+
104
+ This strongly suggests a server configuration or downtime issue; wait
105
+ a few minutes and try again.
106
+
107
+ You can monitor PyPI's status here: https://status.python.org/
108
+ """
109
+ )
110
+
111
+ # On failure, the JSON response includes the list of errors that
112
+ # occurred during minting.
113
+ if not mint_token_resp.ok:
114
+ reasons = "\n".join(
115
+ f'* `{error["code"]}`: {error["description"]}' for error in mint_token_payload["errors"]
116
+ )
117
+
118
+ rendered_claims = _render_claims(oidc_token)
119
+
120
+ _fatal(
121
+ f"""
122
+ Token request failed: the server refused the request for the following reasons:
123
+
124
+ {reasons}
125
+
126
+ This generally indicates a trusted publisher configuration error, but could
127
+ also indicate an internal error on GitHub or PyPI's part.
128
+
129
+ {rendered_claims}
130
+ """
131
+ )
132
+
133
+ pypi_token = mint_token_payload.get("token")
134
+ if not isinstance(pypi_token, str):
135
+ _fatal(
136
+ """
137
+ Token response error: the index gave us an invalid response.
138
+
139
+ This strongly suggests a server configuration or downtime issue; wait
140
+ a few minutes and try again.
141
+ """
142
+ )
143
+
144
+ # Mask the newly minted PyPI token, so that we don't accidentally leak it in logs.
145
+ print(f"::add-mask::{pypi_token}")
146
+
147
+ # This final print will be captured by the subshell in `twine-upload.sh`.
148
+ return pypi_token
149
+
150
+
151
+ def pypi_login() -> None:
152
+ """
153
+ Connect to PyPI using OpenID Connect and mint a token for the user.
154
+
155
+ See Also
156
+ - https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect
157
+ - https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-pypi
158
+ """
159
+ pypirc_filename = os.path.expanduser("~/.pypirc")
160
+
161
+ if os.path.exists(pypirc_filename):
162
+ print(f"::info::{pypirc_filename} already exists; consider as already logged in.")
163
+ return
164
+
165
+ if "ACTIONS_ID_TOKEN_REQUEST_TOKEN" not in os.environ:
166
+ print(
167
+ """::error::Not available, you probably miss the permission `id-token: write`.
168
+ ```
169
+ permissions:
170
+ id-token: write
171
+ ```
172
+ See also: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect"""
173
+ )
174
+ return
175
+
176
+ try:
177
+ token = _get_token("pypi.org")
178
+ with open(pypirc_filename, "w", encoding="utf-8") as pypirc_file:
179
+ pypirc_file.write("[pypi]\n")
180
+ pypirc_file.write("repository: https://upload.pypi.org/legacy/\n")
181
+ pypirc_file.write("username: __token__\n")
182
+ pypirc_file.write(f"password: {token}\n")
183
+ except _OidcError:
184
+ # Already visible in logs; no need to re-raise.
185
+ return
@@ -20,6 +20,7 @@ import c2cciutils
20
20
  import c2cciutils.configuration
21
21
  import c2cciutils.env
22
22
  import c2cciutils.lib.docker
23
+ import c2cciutils.lib.oidc
23
24
  import c2cciutils.publish
24
25
  import c2cciutils.scripts.download_applications
25
26
  from c2cciutils.publish import GoogleCalendar
@@ -170,6 +171,9 @@ def main() -> None:
170
171
  config.get("publish", {}).get("pypi", {}) if config.get("publish", {}).get("pypi", False) else {},
171
172
  )
172
173
  if pypi_config:
174
+ if pypi_config["packages"]:
175
+ c2cciutils.lib.oidc.pypi_login()
176
+
173
177
  for package in pypi_config["packages"]:
174
178
  if package.get("group", c2cciutils.configuration.PUBLISH_PIP_PACKAGE_GROUP_DEFAULT) == args.group:
175
179
  publish = version_type in pypi_config.get("versions", [])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: c2cciutils
3
- Version: 1.7.0.dev294
3
+ Version: 1.7.0.dev297
4
4
  Summary: Common utilities for Camptocamp CI
5
5
  Home-page: https://github.com/camptocamp/c2cciutils
6
6
  License: FreeBSD
@@ -34,6 +34,7 @@ Requires-Dist: defusedxml (>=0.0.0,<1.0.0)
34
34
  Requires-Dist: google-api-python-client (>=2.0.0,<3.0.0) ; extra == "publish"
35
35
  Requires-Dist: google-auth-httplib2 (>=0.0.0,<1.0.0) ; extra == "publish"
36
36
  Requires-Dist: google-auth-oauthlib (>=1.0.0,<2.0.0) ; extra == "publish"
37
+ Requires-Dist: id (>=1.0.0,<2.0.0) ; extra == "publish"
37
38
  Requires-Dist: markdown-table (>=2020.0.0,<2021.0.0)
38
39
  Requires-Dist: multi-repo-automation (>=1.0.0,<2.0.0) ; extra == "version"
39
40
  Requires-Dist: python-magic (>=0.0.0,<1.0.0)
@@ -272,6 +273,17 @@ Then by default:
272
273
  - Commit on `master` branch after the tag 1.3.0 => release `1.4.0.dev1`
273
274
  - Commit on `1.3` branch after the tag 1.3.0 => release `1.3.1.dev1`
274
275
 
276
+ #### Authentication
277
+
278
+ If the file `~/.pypirc` exists we consider that we ar already logged in also
279
+ we will do the login with the `pypi` server with OpenID Connect (OIDC).
280
+
281
+ The OIDC login is recommended because it didn't needs any additional secrets,
282
+ but it need some configuration on pypi in the package,
283
+ see the [GitHub Documentation](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-pypi#adding-the-identity-provider-to-pypi).
284
+
285
+ #### Integration if the package directly in a Docker image
286
+
275
287
  To make it working in the `Dockerfile` you should have in the `poetry` stage:
276
288
 
277
289
  ```Dockerfile
@@ -9,6 +9,7 @@ c2cciutils/configuration.py,sha256=yGv9L9OVAMb1Rnxt4NKf92pLNl7zHnbXeyUgKnhE2Vs,2
9
9
  c2cciutils/default_branch.graphql,sha256=CaP3rRsNiyg_7RvqbMk0tOJr0aqWd8cOeSV-ZKgvKY4,131
10
10
  c2cciutils/env.py,sha256=J-lC7GdOkdFVIrWFZEkAxHmIuTYwhDJiE30BICj2UoM,3425
11
11
  c2cciutils/lib/docker.py,sha256=lwvCarwSSUWK1Y4O7qcTILPdpkTf2Ujhl_fCwZ6dBUY,5677
12
+ c2cciutils/lib/oidc.py,sha256=NWhLdVlpvzhk5_BcogodpLypcZo7sr9XtGCVbUmaiFM,6261
12
13
  c2cciutils/package-lock.json,sha256=QkNaqlUrLMW3qvYQSoEMQCoWOx-0CaQ5-u4hkDUEHpc,15611
13
14
  c2cciutils/package.json,sha256=A3gItP1CsTXzsMdigeCu3fNeltY08nYVs_LCU4B5PJs,134
14
15
  c2cciutils/pr_checks.py,sha256=tBwDHxThcu6648pE2cqpLNsaU711lwwgRc7sB4qR6fU,10109
@@ -30,11 +31,11 @@ c2cciutils/scripts/k8s/wait.py,sha256=qzQn6hbB9p1CX4bUxrkukPnbu_p6oRNem29WiMtplN
30
31
  c2cciutils/scripts/main.py,sha256=ZksoYEDRJD0igEU6i0PnuOFtch4OzsxyHZQxbrjd5AY,1029
31
32
  c2cciutils/scripts/pin_pipenv.py,sha256=jBTwlolcEL0MUyq6VYzO-adkcL1gqN7B3kBb3UjTo2k,2150
32
33
  c2cciutils/scripts/pr_checks.py,sha256=PA9z9QB81H2JhGSr4T02eoxyeWDjQZ4XoIKFzS5o5A0,2190
33
- c2cciutils/scripts/publish.py,sha256=4bFgsaUssKrP_N7lG9FILalQ2oexyxCCcC1ofqcxqTU,20313
34
+ c2cciutils/scripts/publish.py,sha256=lc75i0BP4zHKWqeWpseVY1-hO4mJVTy57zEOCmeideg,20422
34
35
  c2cciutils/scripts/trigger_image_update.py,sha256=UPCSgFcllewo1NOC7kUkJ2QMXU0dCA2QAq6LFQHr0Uw,2780
35
36
  c2cciutils/scripts/version.py,sha256=BU6I3nG3ofgUXCLrUBNOql45Dz9Loox4gt4ebHRM3iQ,8912
36
- c2cciutils-1.7.0.dev294.dist-info/LICENSE,sha256=EMCYfDu0AgsMQO6k8Hl_xHzoFxM0db1xu9n_asZW9Vc,1307
37
- c2cciutils-1.7.0.dev294.dist-info/METADATA,sha256=oOpQqeKiHtudDAliW9LcpZcdn29IfOcqi2usv1lBDJo,18524
38
- c2cciutils-1.7.0.dev294.dist-info/entry_points.txt,sha256=jPDp7KeB0Fz_TpOwbOODeW2WEcdLNJZACPtKpRqtHs4,1030
39
- c2cciutils-1.7.0.dev294.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
40
- c2cciutils-1.7.0.dev294.dist-info/RECORD,,
37
+ c2cciutils-1.7.0.dev297.dist-info/LICENSE,sha256=EMCYfDu0AgsMQO6k8Hl_xHzoFxM0db1xu9n_asZW9Vc,1307
38
+ c2cciutils-1.7.0.dev297.dist-info/METADATA,sha256=kguZTW0ZOG-rBTEt1wKjS93dmBzhb2I4_ku1OOgyryw,19149
39
+ c2cciutils-1.7.0.dev297.dist-info/entry_points.txt,sha256=jPDp7KeB0Fz_TpOwbOODeW2WEcdLNJZACPtKpRqtHs4,1030
40
+ c2cciutils-1.7.0.dev297.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
41
+ c2cciutils-1.7.0.dev297.dist-info/RECORD,,