libre-devops-helpers 0.4.1__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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,241 @@
1
+ """Tokens for an app registration, straight from the Entra ID token endpoint.
2
+
3
+ Two client credential flows, for automation that has no Azure CLI sign-in:
4
+
5
+ - ``ClientSecretCredential``: a client secret, read from the environment by the caller.
6
+ - ``WorkloadIdentityCredential``: a federated token (OIDC) exchanged for an access
7
+ token, so no secret exists at all. The federated token comes from a file (AKS and
8
+ most CI systems) or from the GitHub Actions OIDC endpoint.
9
+
10
+ Secrets and assertions are sent in the form body only and never logged or repr'd.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import datetime, timedelta
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import requests
22
+
23
+ from libre_devops_helpers.core.auth import AccessToken, utc_now
24
+ from libre_devops_helpers.core.errors import ApiError, AuthError
25
+ from libre_devops_helpers.core.http import ApiClient
26
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
27
+
28
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
29
+ # The audience Entra ID expects on a federated token.
30
+ EXCHANGE_AUDIENCE = "api://AzureADTokenExchange"
31
+
32
+
33
+ def scope_for(resource: str) -> str:
34
+ """The v2 ``.default`` scope for a v1 resource URL."""
35
+ return resource.rstrip("/") + "/.default"
36
+
37
+
38
+ class _TokenEndpoint:
39
+ """POSTs a client credential grant to ``{login_url}/{tenant}/oauth2/v2.0/token``."""
40
+
41
+ def __init__(
42
+ self,
43
+ login_url: str,
44
+ *,
45
+ name: str,
46
+ session: requests.Session | None,
47
+ verify: bool | str,
48
+ clock: Callable[[], datetime],
49
+ sleep: Callable[[float], None],
50
+ ) -> None:
51
+ self._api = ApiClient(
52
+ login_url, None, name=name, session=session, verify=verify, sleep=sleep
53
+ )
54
+ self._name = name
55
+ self._clock = clock
56
+
57
+ def token(self, resource: str, tenant_id: str, form: Mapping[str, str]) -> AccessToken:
58
+ now = self._clock()
59
+ try:
60
+ data = self._api.request(
61
+ "POST",
62
+ f"/{tenant_id}/oauth2/v2.0/token",
63
+ form={**form, "grant_type": "client_credentials", "scope": scope_for(resource)},
64
+ )
65
+ except ApiError as exc:
66
+ raise AuthError(str(exc), hint=_hint(exc)) from None
67
+ return parse_token_response(data, resource, tenant_id, now=now, name=self._name)
68
+
69
+
70
+ def parse_token_response(
71
+ data: Mapping[str, Any], resource: str, tenant_id: str, *, now: datetime, name: str
72
+ ) -> AccessToken:
73
+ """An AccessToken from an OAuth token response (``expires_in`` or ``expires_on``)."""
74
+ token = data.get("access_token")
75
+ if not isinstance(token, str) or not token:
76
+ raise AuthError(f"{name}: the token response has no access_token")
77
+ expires_in = _number(data.get("expires_in"))
78
+ expires_on = _number(data.get("expires_on"))
79
+ if expires_in is not None:
80
+ expiry = now + timedelta(seconds=expires_in)
81
+ elif expires_on is not None:
82
+ expiry = datetime.fromtimestamp(expires_on, now.tzinfo)
83
+ else:
84
+ raise AuthError(f"{name}: the token response has no expiry")
85
+ return AccessToken(token=token, expires_on=expiry, tenant_id=tenant_id, resource=resource)
86
+
87
+
88
+ class ClientSecretCredential:
89
+ """A token provider for an app registration authenticating with a client secret."""
90
+
91
+ def __init__(
92
+ self,
93
+ client_id: str,
94
+ client_secret: str,
95
+ *,
96
+ login_url: str = PUBLIC.login_url,
97
+ session: requests.Session | None = None,
98
+ verify: bool | str = True,
99
+ clock: Callable[[], datetime] = utc_now,
100
+ sleep: Callable[[float], None] = time.sleep,
101
+ ) -> None:
102
+ if not client_secret:
103
+ raise AuthError("the client secret is empty")
104
+ self.client_id = client_id
105
+ self._secret = client_secret
106
+ self._endpoint = _TokenEndpoint(
107
+ login_url,
108
+ name="Entra ID token endpoint",
109
+ session=session,
110
+ verify=verify,
111
+ clock=clock,
112
+ sleep=sleep,
113
+ )
114
+
115
+ def __repr__(self) -> str:
116
+ return f"ClientSecretCredential(client_id={self.client_id!r})"
117
+
118
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
119
+ return self._endpoint.token(
120
+ resource, tenant_id, {"client_id": self.client_id, "client_secret": self._secret}
121
+ )
122
+
123
+
124
+ class WorkloadIdentityCredential:
125
+ """A token provider that exchanges a federated token for an access token.
126
+
127
+ ``assertion`` is called for every token request, because federated tokens are
128
+ short-lived and rotated by whatever issues them.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ client_id: str,
134
+ assertion: Callable[[], str],
135
+ *,
136
+ login_url: str = PUBLIC.login_url,
137
+ session: requests.Session | None = None,
138
+ verify: bool | str = True,
139
+ clock: Callable[[], datetime] = utc_now,
140
+ sleep: Callable[[float], None] = time.sleep,
141
+ ) -> None:
142
+ self.client_id = client_id
143
+ self._assertion = assertion
144
+ self._endpoint = _TokenEndpoint(
145
+ login_url,
146
+ name="Entra ID token endpoint",
147
+ session=session,
148
+ verify=verify,
149
+ clock=clock,
150
+ sleep=sleep,
151
+ )
152
+
153
+ def __repr__(self) -> str:
154
+ return f"WorkloadIdentityCredential(client_id={self.client_id!r})"
155
+
156
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
157
+ return self._endpoint.token(
158
+ resource,
159
+ tenant_id,
160
+ {
161
+ "client_id": self.client_id,
162
+ "client_assertion_type": ASSERTION_TYPE,
163
+ "client_assertion": self._assertion(),
164
+ },
165
+ )
166
+
167
+
168
+ def federated_token_file(path: Path) -> Callable[[], str]:
169
+ """An assertion source that re-reads ``path`` each time (the file is rotated)."""
170
+
171
+ def read() -> str:
172
+ try:
173
+ value = path.read_text(encoding="utf-8").strip()
174
+ except OSError as exc:
175
+ raise AuthError(f"cannot read the federated token file {path}: {exc}") from None
176
+ if not value:
177
+ raise AuthError(f"the federated token file {path} is empty")
178
+ return value
179
+
180
+ return read
181
+
182
+
183
+ def github_actions_assertion(
184
+ request_url: str,
185
+ request_token: str,
186
+ *,
187
+ audience: str = EXCHANGE_AUDIENCE,
188
+ session: requests.Session | None = None,
189
+ verify: bool | str = True,
190
+ sleep: Callable[[float], None] = time.sleep,
191
+ ) -> Callable[[], str]:
192
+ """An assertion source that asks the GitHub Actions OIDC endpoint for an ID token.
193
+
194
+ ``request_url`` and ``request_token`` are the runner's ``ACTIONS_ID_TOKEN_REQUEST_URL``
195
+ and ``ACTIONS_ID_TOKEN_REQUEST_TOKEN``; the job needs ``permissions: id-token: write``.
196
+ """
197
+ api = ApiClient(
198
+ request_url,
199
+ lambda: request_token,
200
+ name="GitHub Actions OIDC",
201
+ session=session,
202
+ verify=verify,
203
+ sleep=sleep,
204
+ )
205
+
206
+ def fetch() -> str:
207
+ try:
208
+ data = api.get(request_url, params={"audience": audience})
209
+ except ApiError as exc:
210
+ raise AuthError(
211
+ str(exc), hint="the workflow job needs 'permissions: id-token: write'"
212
+ ) from None
213
+ value = data.get("value")
214
+ if not isinstance(value, str) or not value:
215
+ raise AuthError("GitHub Actions OIDC: the response has no token value")
216
+ return value
217
+
218
+ return fetch
219
+
220
+
221
+ def _number(value: object) -> float | None:
222
+ if isinstance(value, bool):
223
+ return None
224
+ if isinstance(value, int | float):
225
+ return float(value)
226
+ if isinstance(value, str) and value.strip().isdigit():
227
+ return float(value.strip())
228
+ return None
229
+
230
+
231
+ def _hint(exc: ApiError) -> str | None:
232
+ text = str(exc)
233
+ if "AADSTS7000215" in text or "AADSTS7000222" in text:
234
+ return "the client secret is wrong or expired; check AZURE_CLIENT_SECRET"
235
+ if "AADSTS700016" in text:
236
+ return "no app with this client_id exists in the tenant; check the profile's client_id"
237
+ if "AADSTS70021" in text or "AADSTS700213" in text:
238
+ return "no federated credential on the app matches this token's issuer and subject"
239
+ if "AADSTS90002" in text:
240
+ return "no such tenant: check the profile's tenant_id"
241
+ return "check the profile's client_id and tenant_id, and the app's credentials"
@@ -0,0 +1,114 @@
1
+ """Build the credential a profile's ``auth`` setting names.
2
+
3
+ Secrets never come from the config file. They come from the environment, under the
4
+ same names the Azure SDKs use, so a CI job configured for one works for the other.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Callable, Mapping
11
+ from pathlib import Path
12
+
13
+ import requests
14
+
15
+ from libre_devops_helpers.core.auth import TokenProvider
16
+ from libre_devops_helpers.core.errors import AuthError
17
+ from libre_devops_helpers.core.token_store import TokenStore, open_store
18
+ from libre_devops_helpers.microsoft.auth.azure_cli import AzureCliCredential, Reauthenticate
19
+ from libre_devops_helpers.microsoft.auth.delegated import (
20
+ DeviceCodeCredential,
21
+ InteractiveCredential,
22
+ )
23
+ from libre_devops_helpers.microsoft.auth.entra import (
24
+ ClientSecretCredential,
25
+ WorkloadIdentityCredential,
26
+ federated_token_file,
27
+ github_actions_assertion,
28
+ )
29
+ from libre_devops_helpers.microsoft.auth.managed_identity import (
30
+ ManagedIdentityCredential,
31
+ )
32
+ from libre_devops_helpers.microsoft.config import DELEGATED_AUTH, Profile
33
+ from libre_devops_helpers.microsoft.process import AzureCliRunner
34
+
35
+ SECRET_VARIABLE = "AZURE_CLIENT_SECRET"
36
+ TOKEN_FILE_VARIABLE = "AZURE_FEDERATED_TOKEN_FILE"
37
+ GITHUB_URL_VARIABLE = "ACTIONS_ID_TOKEN_REQUEST_URL"
38
+ GITHUB_TOKEN_VARIABLE = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
39
+
40
+
41
+ def credential_for(
42
+ profile: Profile,
43
+ *,
44
+ runner: AzureCliRunner | None = None,
45
+ session: requests.Session | None = None,
46
+ verify: bool | str = True,
47
+ environ: Mapping[str, str] = os.environ,
48
+ notify: Callable[[str], None] | None = None,
49
+ reauthenticate: Reauthenticate | None = None,
50
+ token_store: TokenStore | None = None,
51
+ ) -> TokenProvider:
52
+ """The token provider for ``profile``. Raises AuthError when its inputs are missing.
53
+
54
+ ``reauthenticate`` is offered to the Azure CLI credential, to sign the CLI in again
55
+ when its session has lapsed; see AzureCliCredential. ``token_store`` keeps an
56
+ interactive or device-code sign-in between commands; without it, the store the
57
+ profile's ``token_cache`` names is opened.
58
+ """
59
+ login_url = profile.cloud.login_url
60
+ if profile.auth == "azure-cli":
61
+ return AzureCliCredential(runner, reauthenticate=reauthenticate)
62
+ if profile.auth == "managed-identity":
63
+ return ManagedIdentityCredential(profile.client_id, environ=environ, session=session)
64
+
65
+ client_id = profile.client_id
66
+ if client_id is None:
67
+ raise AuthError(f"profile {profile.name!r} uses auth = {profile.auth!r} without client_id")
68
+
69
+ if profile.auth in DELEGATED_AUTH:
70
+ flow = InteractiveCredential if profile.auth == "interactive" else DeviceCodeCredential
71
+ return flow(
72
+ client_id,
73
+ login_url=login_url,
74
+ session=session,
75
+ verify=verify,
76
+ notify=notify,
77
+ store=token_store or open_store(profile.token_cache, environ=environ),
78
+ )
79
+
80
+ if profile.auth == "client-secret":
81
+ secret = environ.get(SECRET_VARIABLE, "")
82
+ if not secret:
83
+ raise AuthError(
84
+ f"profile {profile.name!r} needs a client secret",
85
+ hint=f"set {SECRET_VARIABLE}; the config file never holds secrets",
86
+ )
87
+ return ClientSecretCredential(
88
+ client_id, secret, login_url=login_url, session=session, verify=verify
89
+ )
90
+
91
+ if profile.auth == "workload-identity":
92
+ token_file = environ.get(TOKEN_FILE_VARIABLE)
93
+ if token_file:
94
+ assertion = federated_token_file(Path(token_file))
95
+ elif environ.get(GITHUB_URL_VARIABLE) and environ.get(GITHUB_TOKEN_VARIABLE):
96
+ assertion = github_actions_assertion(
97
+ environ[GITHUB_URL_VARIABLE],
98
+ environ[GITHUB_TOKEN_VARIABLE],
99
+ session=session,
100
+ verify=verify,
101
+ )
102
+ else:
103
+ raise AuthError(
104
+ f"profile {profile.name!r} has no federated token to exchange",
105
+ hint=(
106
+ f"set {TOKEN_FILE_VARIABLE}, or run in GitHub Actions with "
107
+ "'permissions: id-token: write'"
108
+ ),
109
+ )
110
+ return WorkloadIdentityCredential(
111
+ client_id, assertion, login_url=login_url, session=session, verify=verify
112
+ )
113
+
114
+ raise AuthError(f"profile {profile.name!r} has an unknown auth method {profile.auth!r}")
@@ -0,0 +1,42 @@
1
+ """Why a sign-in lapsed: the Entra ID errors that only a fresh sign-in can clear.
2
+
3
+ An access token lasts about an hour, and a refresh token renews it without asking
4
+ anyone. These errors mean the refresh token itself can no longer be used, so retrying
5
+ cannot help: someone has to sign in again. Naming the cause says whether that will keep
6
+ happening (a sign-in frequency policy) or was a one-off (a password change).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # AADSTS code -> the cause, in plain words.
14
+ REASONS = {
15
+ "AADSTS700082": "the session went unused for too long, so its refresh token expired",
16
+ "AADSTS70043": "a Conditional Access sign-in frequency policy wants a fresh sign-in",
17
+ "AADSTS70044": "a Conditional Access sign-in frequency policy wants a fresh sign-in",
18
+ "AADSTS50173": "the session was revoked (by an administrator, or by signing out everywhere)",
19
+ "AADSTS50132": "a password change or expiry ended the session",
20
+ "AADSTS50133": "a password change or expiry ended the session",
21
+ "AADSTS50055": "the password has expired",
22
+ "AADSTS50076": "multi-factor authentication is now required",
23
+ "AADSTS50078": "the multi-factor authentication on the session has expired",
24
+ "AADSTS50079": "multi-factor authentication must be set up for this account",
25
+ "AADSTS50158": "an external security challenge was not satisfied",
26
+ }
27
+ _CODE = re.compile(r"AADSTS\d+")
28
+ _SIGNED_OUT = "az login"
29
+
30
+
31
+ def lapse_reason(message: str) -> str | None:
32
+ """The cause when ``message`` says a sign-in lapsed, else None.
33
+
34
+ Knows the Entra ID codes above, and the Azure CLI's own ways of saying it has no
35
+ usable session (its messages all tell you to run ``az login``).
36
+ """
37
+ for code in _CODE.findall(message):
38
+ if code in REASONS:
39
+ return REASONS[code]
40
+ if _SIGNED_OUT in message:
41
+ return "the Azure CLI has no usable sign-in for this tenant"
42
+ return None
@@ -0,0 +1,84 @@
1
+ """Tokens for an Azure managed identity, from the endpoint of the host it runs on.
2
+
3
+ App Service and Azure Functions publish ``IDENTITY_ENDPOINT`` and ``IDENTITY_HEADER``;
4
+ virtual machines, scale sets and most other hosts use the instance metadata service
5
+ (IMDS) at 169.254.169.254. Both are plain http on a local address by design; no bearer
6
+ token is sent to either, so the http client allows them explicitly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import time
13
+ from collections.abc import Callable, Mapping
14
+ from datetime import datetime
15
+
16
+ import requests
17
+
18
+ from libre_devops_helpers.core.auth import AccessToken, utc_now
19
+ from libre_devops_helpers.core.errors import ApiError, AuthError
20
+ from libre_devops_helpers.core.http import ApiClient
21
+ from libre_devops_helpers.microsoft.auth.entra import parse_token_response
22
+
23
+ IMDS_URL = "http://169.254.169.254/metadata/identity/oauth2/token"
24
+
25
+
26
+ class ManagedIdentityCredential:
27
+ """A token provider for the managed identity of the Azure host this runs on.
28
+
29
+ ``client_id`` picks a user-assigned identity; without it the system-assigned one is
30
+ used. A managed identity belongs to one tenant, so ``tenant_id`` is not sent, and the
31
+ token checks catch a profile that names a different tenant.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ client_id: str | None = None,
37
+ *,
38
+ environ: Mapping[str, str] = os.environ,
39
+ session: requests.Session | None = None,
40
+ clock: Callable[[], datetime] = utc_now,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ ) -> None:
43
+ self.client_id = client_id
44
+ self._clock = clock
45
+ endpoint = environ.get("IDENTITY_ENDPOINT")
46
+ header = environ.get("IDENTITY_HEADER")
47
+ if endpoint and header:
48
+ self.source = "App Service"
49
+ self._url = endpoint
50
+ self._headers = {"X-IDENTITY-HEADER": header}
51
+ self._version = "2019-08-01"
52
+ else:
53
+ self.source = "IMDS"
54
+ self._url = IMDS_URL
55
+ self._headers = {"Metadata": "true"}
56
+ self._version = "2018-02-01"
57
+ # A short timeout: off Azure, IMDS never answers, and that should fail fast.
58
+ self._api = ApiClient(
59
+ self._url,
60
+ None,
61
+ name=f"managed identity ({self.source})",
62
+ session=session,
63
+ timeout=5.0,
64
+ max_attempts=3,
65
+ sleep=sleep,
66
+ allow_http=True,
67
+ )
68
+
69
+ def __repr__(self) -> str:
70
+ return f"ManagedIdentityCredential(client_id={self.client_id!r}, source={self.source!r})"
71
+
72
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
73
+ params = {"api-version": self._version, "resource": resource}
74
+ if self.client_id:
75
+ params["client_id"] = self.client_id
76
+ now = self._clock()
77
+ try:
78
+ data = self._api.get(self._url, params=params, headers=self._headers)
79
+ except ApiError as exc:
80
+ raise AuthError(
81
+ str(exc),
82
+ hint="managed identity only works on an Azure host with an identity assigned",
83
+ ) from None
84
+ return parse_token_response(data, resource, tenant_id, now=now, name=self._api.name)
@@ -0,0 +1,24 @@
1
+ """Azure Automation: accounts, the jobs their runbooks ran, and each job's logs."""
2
+
3
+ from libre_devops_helpers.microsoft.automation.client import API_VERSION, AutomationClient
4
+ from libre_devops_helpers.microsoft.automation.models import (
5
+ FAILED,
6
+ STREAMS,
7
+ AutomationAccount,
8
+ Job,
9
+ JobStream,
10
+ )
11
+
12
+ # ARM tokens carry no scopes to check: Azure RBAC decides (Reader is enough to read jobs).
13
+ REQUIREMENTS: tuple = ()
14
+
15
+ __all__ = [
16
+ "API_VERSION",
17
+ "FAILED",
18
+ "REQUIREMENTS",
19
+ "STREAMS",
20
+ "AutomationAccount",
21
+ "AutomationClient",
22
+ "Job",
23
+ "JobStream",
24
+ ]