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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|