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,268 @@
|
|
|
1
|
+
"""ServiceNow commands: sign in, who you are, the instance, and its applications."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.cli import render
|
|
8
|
+
from libre_devops_helpers.cli.exits import ATTENTION
|
|
9
|
+
from libre_devops_helpers.cli.options import OutputOption, get_runtime
|
|
10
|
+
from libre_devops_helpers.cli.render import Output
|
|
11
|
+
from libre_devops_helpers.cli.runtime import Runtime
|
|
12
|
+
from libre_devops_helpers.core import brand
|
|
13
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
14
|
+
from libre_devops_helpers.servicenow import OAuthCredential
|
|
15
|
+
from libre_devops_helpers.servicenow import instance as instance_feature
|
|
16
|
+
|
|
17
|
+
snow_app = typer.Typer(
|
|
18
|
+
rich_markup_mode="markdown",
|
|
19
|
+
name="snow",
|
|
20
|
+
help="ServiceNow: sign in, who you are, the instance and its applications.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Every ServiceNow feature declares the roles its calls need; whoami reports each one.
|
|
25
|
+
REQUIREMENTS = (*instance_feature.REQUIREMENTS,)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register(app: typer.Typer) -> None:
|
|
29
|
+
app.add_typer(snow_app)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _complete_profile(incomplete: str) -> list[str]:
|
|
33
|
+
try:
|
|
34
|
+
names = [profile.name for profile in Runtime().servicenow.profiles()]
|
|
35
|
+
except LdoError:
|
|
36
|
+
return []
|
|
37
|
+
return [name for name in sorted(names) if name.startswith(incomplete)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ProfileOption = Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--profile",
|
|
44
|
+
"-p",
|
|
45
|
+
envvar=brand.env_var("SNOW_PROFILE"),
|
|
46
|
+
help="ServiceNow profile. Default: default_profile, else the one from the environment.",
|
|
47
|
+
autocompletion=_complete_profile,
|
|
48
|
+
show_default=False,
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@snow_app.command("sign-in")
|
|
54
|
+
def sign_in(ctx: typer.Context, profile: ProfileOption = None) -> None:
|
|
55
|
+
"""Sign in afresh and keep the sign-in, so later commands do not ask.
|
|
56
|
+
|
|
57
|
+
With sign_in = "browser" (the default) you get a link to open in any browser, where
|
|
58
|
+
you sign in as you do to the instance (single sign-on and MFA too); then paste back
|
|
59
|
+
the address it lands on. With sign_in = "password" you are asked for the password,
|
|
60
|
+
unless it is in the environment.
|
|
61
|
+
"""
|
|
62
|
+
runtime = get_runtime(ctx).servicenow
|
|
63
|
+
selected = runtime.profile(profile)
|
|
64
|
+
credential = runtime.credential(selected)
|
|
65
|
+
if not isinstance(credential, OAuthCredential):
|
|
66
|
+
raise LdoError(
|
|
67
|
+
f"profile {selected.name!r} uses basic sign-in, which sends the password each time",
|
|
68
|
+
hint=runtime.oauth_hint(selected),
|
|
69
|
+
)
|
|
70
|
+
credential.sign_in()
|
|
71
|
+
user = runtime.instance(selected).current_user()
|
|
72
|
+
render.note(
|
|
73
|
+
f"Signed in to {selected.host} as {user.user_name} ({user.name}). "
|
|
74
|
+
f"The sign-in is kept ({selected.token_cache}); "
|
|
75
|
+
f"{brand.command(f'snow sign-out -p {selected.name}')} forgets it."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@snow_app.command("sign-out")
|
|
80
|
+
def sign_out(ctx: typer.Context, profile: ProfileOption = None) -> None:
|
|
81
|
+
"""Forget the kept sign-in, so the next command signs in afresh."""
|
|
82
|
+
runtime = get_runtime(ctx).servicenow
|
|
83
|
+
selected = runtime.profile(profile)
|
|
84
|
+
credential = runtime.credential(selected)
|
|
85
|
+
if not isinstance(credential, OAuthCredential):
|
|
86
|
+
render.note(f"profile {selected.name!r} uses basic sign-in, so nothing is kept")
|
|
87
|
+
return
|
|
88
|
+
if credential.sign_out():
|
|
89
|
+
render.note(f"Forgot the sign-in kept for {selected.name} ({selected.host}).")
|
|
90
|
+
else:
|
|
91
|
+
render.note(f"No sign-in was kept for {selected.name}.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@snow_app.command("whoami")
|
|
95
|
+
def whoami(
|
|
96
|
+
ctx: typer.Context, profile: ProfileOption = None, output: OutputOption = Output.TABLE
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Show who you are signed in as, your roles, and which features they cover."""
|
|
99
|
+
runtime = get_runtime(ctx).servicenow
|
|
100
|
+
selected = runtime.profile(profile)
|
|
101
|
+
client = runtime.instance(selected)
|
|
102
|
+
user = client.current_user()
|
|
103
|
+
roles = client.roles(user)
|
|
104
|
+
covers = [(item.feature, item.met_by(roles)) for item in REQUIREMENTS]
|
|
105
|
+
record = {
|
|
106
|
+
"profile": selected.name,
|
|
107
|
+
"instance": selected.instance,
|
|
108
|
+
"auth": selected.auth,
|
|
109
|
+
"sign_in": selected.sign_in if selected.auth == "oauth" else None,
|
|
110
|
+
"user": dict(user.raw),
|
|
111
|
+
"roles": list(roles),
|
|
112
|
+
"covers": {feature: met for feature, met in covers},
|
|
113
|
+
}
|
|
114
|
+
if output is not Output.TABLE:
|
|
115
|
+
render.emit(
|
|
116
|
+
output,
|
|
117
|
+
["PROFILE", "INSTANCE", "USER", "NAME", "ROLES"],
|
|
118
|
+
[[selected.name, selected.host, user.user_name, user.name, " ".join(roles)]],
|
|
119
|
+
record,
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
method = f"oauth ({selected.sign_in})" if selected.auth == "oauth" else selected.auth
|
|
123
|
+
render.echo(
|
|
124
|
+
render.pairs(
|
|
125
|
+
[
|
|
126
|
+
("Profile", selected.name),
|
|
127
|
+
("Instance", selected.instance),
|
|
128
|
+
("Sign-in", method),
|
|
129
|
+
("User", f"{user.user_name} ({user.name})"),
|
|
130
|
+
("Email", user.email),
|
|
131
|
+
("Roles", ", ".join(roles) or "(none)"),
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
render.echo()
|
|
136
|
+
render.echo(
|
|
137
|
+
render.table(
|
|
138
|
+
["FEATURE", "COVERED"],
|
|
139
|
+
[[feature, ("yes", "green") if met else ("no", "yellow")] for feature, met in covers],
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if user.locked_out:
|
|
143
|
+
render.warn("the account is locked out")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@snow_app.command("token")
|
|
147
|
+
def token(
|
|
148
|
+
ctx: typer.Context,
|
|
149
|
+
profile: ProfileOption = None,
|
|
150
|
+
raw: Annotated[bool, typer.Option("--raw", help="Print only the token, for piping.")] = False,
|
|
151
|
+
output: OutputOption = Output.TABLE,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Get an access token for an OAuth profile, and say how long it lasts.
|
|
154
|
+
|
|
155
|
+
The token itself is shown only with --raw. It lasts 30 minutes by default; the kept
|
|
156
|
+
refresh token renews it without asking.
|
|
157
|
+
"""
|
|
158
|
+
runtime = get_runtime(ctx).servicenow
|
|
159
|
+
selected = runtime.profile(profile)
|
|
160
|
+
tokens = runtime.tokens(selected)
|
|
161
|
+
access = tokens.get_token(selected.instance, "")
|
|
162
|
+
if raw:
|
|
163
|
+
typer.echo(access.token)
|
|
164
|
+
return
|
|
165
|
+
credential = runtime.credential(selected)
|
|
166
|
+
kept = isinstance(credential, OAuthCredential) and credential.has_kept_sign_in()
|
|
167
|
+
record = {
|
|
168
|
+
"profile": selected.name,
|
|
169
|
+
"instance": selected.instance,
|
|
170
|
+
"expires_on": access.expires_on.isoformat(),
|
|
171
|
+
"sign_in_kept": kept,
|
|
172
|
+
"token_cache": selected.token_cache,
|
|
173
|
+
}
|
|
174
|
+
render.emit(
|
|
175
|
+
output,
|
|
176
|
+
["PROFILE", "INSTANCE", "EXPIRES", "SIGN-IN KEPT"],
|
|
177
|
+
[[selected.name, selected.host, render.when(access.expires_on), render.yes_no(kept)]],
|
|
178
|
+
record,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@snow_app.command("instance")
|
|
183
|
+
def instance(
|
|
184
|
+
ctx: typer.Context, profile: ProfileOption = None, output: OutputOption = Output.TABLE
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Show the instance's release, and whether Security Incident Response is installed.
|
|
187
|
+
|
|
188
|
+
Exits 3 when Security Incident Response is not installed.
|
|
189
|
+
"""
|
|
190
|
+
runtime = get_runtime(ctx).servicenow
|
|
191
|
+
selected = runtime.profile(profile)
|
|
192
|
+
client = runtime.instance(selected)
|
|
193
|
+
release = client.release()
|
|
194
|
+
sir = client.security_incident_response()
|
|
195
|
+
record = {
|
|
196
|
+
"profile": selected.name,
|
|
197
|
+
"instance": selected.instance,
|
|
198
|
+
"release": None if release is None else release.build_tag,
|
|
199
|
+
"family": None if release is None else release.family,
|
|
200
|
+
"security_incident_response": {
|
|
201
|
+
"installed": sir.installed,
|
|
202
|
+
"version": sir.version,
|
|
203
|
+
"detail": sir.detail,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
render.emit(
|
|
207
|
+
output,
|
|
208
|
+
["INSTANCE", "RELEASE", "SECURITY INCIDENT RESPONSE"],
|
|
209
|
+
[
|
|
210
|
+
[
|
|
211
|
+
selected.host,
|
|
212
|
+
"unknown" if release is None else release.label,
|
|
213
|
+
("installed" + (f" {sir.version}" if sir.version else ""), "green")
|
|
214
|
+
if sir.installed
|
|
215
|
+
else ("not installed", "yellow"),
|
|
216
|
+
]
|
|
217
|
+
],
|
|
218
|
+
record,
|
|
219
|
+
)
|
|
220
|
+
if not sir.installed:
|
|
221
|
+
render.note(
|
|
222
|
+
"Security Incident Response is not installed. On a developer instance, activate "
|
|
223
|
+
"it from developer.servicenow.com (your instance > Activate Plugin), or search "
|
|
224
|
+
"for it under All > System Applications > All Available Applications."
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(ATTENTION)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@snow_app.command("apps")
|
|
230
|
+
def apps(
|
|
231
|
+
ctx: typer.Context,
|
|
232
|
+
search: Annotated[
|
|
233
|
+
str | None, typer.Argument(help="Part of a name or scope, e.g. 'security'.")
|
|
234
|
+
] = None,
|
|
235
|
+
show_all: Annotated[
|
|
236
|
+
bool, typer.Option("--all", help="Include applications that are not active.")
|
|
237
|
+
] = False,
|
|
238
|
+
profile: ProfileOption = None,
|
|
239
|
+
output: OutputOption = Output.TABLE,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""List the instance's applications (store and custom), optionally matching SEARCH.
|
|
242
|
+
|
|
243
|
+
Security Incident Response, once installed, is listed as scope sn_si. ServiceNow
|
|
244
|
+
closes its plugin tables to the API, so older plugins without a scope do not show.
|
|
245
|
+
"""
|
|
246
|
+
runtime = get_runtime(ctx).servicenow
|
|
247
|
+
selected = runtime.profile(profile)
|
|
248
|
+
found = runtime.instance(selected).applications(search, active_only=not show_all)
|
|
249
|
+
render.emit(
|
|
250
|
+
output,
|
|
251
|
+
["NAME", "SCOPE", "KIND", "STATE", "VERSION"],
|
|
252
|
+
[
|
|
253
|
+
[
|
|
254
|
+
app.name,
|
|
255
|
+
app.scope,
|
|
256
|
+
app.kind,
|
|
257
|
+
("active", "green") if app.active else ("inactive", "bright_black"),
|
|
258
|
+
app.version,
|
|
259
|
+
]
|
|
260
|
+
for app in found
|
|
261
|
+
],
|
|
262
|
+
[dict(app.raw) for app in found],
|
|
263
|
+
)
|
|
264
|
+
render.note(f"{len(found)} application(s)")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# 'plugins' is what people search for; it lists the same applications.
|
|
268
|
+
snow_app.command("plugins", hidden=True)(apps)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Token commands: get a token for a profile, inspect one you have, or forget a sign-in."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.cli import render
|
|
11
|
+
from libre_devops_helpers.cli.exits import ERROR
|
|
12
|
+
from libre_devops_helpers.cli.options import OutputOption, ProfileOption, get_runtime
|
|
13
|
+
from libre_devops_helpers.cli.render import Output
|
|
14
|
+
from libre_devops_helpers.core import brand
|
|
15
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
16
|
+
from libre_devops_helpers.microsoft import entra, graph, incidents, intune, pim, xdr
|
|
17
|
+
from libre_devops_helpers.microsoft.resources import resolve_resource
|
|
18
|
+
from libre_devops_helpers.microsoft.tokens import (
|
|
19
|
+
Check,
|
|
20
|
+
DecodedToken,
|
|
21
|
+
decode_token,
|
|
22
|
+
passed,
|
|
23
|
+
validate_token,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Every feature module declares what its calls need; the token checks report each one.
|
|
27
|
+
REQUIREMENTS = (
|
|
28
|
+
*entra.REQUIREMENTS,
|
|
29
|
+
*xdr.REQUIREMENTS,
|
|
30
|
+
*intune.REQUIREMENTS,
|
|
31
|
+
*pim.REQUIREMENTS,
|
|
32
|
+
*incidents.REQUIREMENTS,
|
|
33
|
+
*graph.REQUIREMENTS,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
RequireOption = Annotated[
|
|
37
|
+
list[str] | None,
|
|
38
|
+
typer.Option("--require", help="Scope or role the token must carry. Repeatable."),
|
|
39
|
+
]
|
|
40
|
+
StrictOption = Annotated[bool, typer.Option("--strict", help="Treat warnings as failures.")]
|
|
41
|
+
AllClaimsOption = Annotated[
|
|
42
|
+
bool, typer.Option("--all-claims", help="Show every claim, not only the summary.")
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register(app: typer.Typer) -> None:
|
|
47
|
+
app.command("token")(token)
|
|
48
|
+
app.command("inspect-token")(inspect_token)
|
|
49
|
+
app.command("sign-out")(sign_out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sign_out(ctx: typer.Context, profile: ProfileOption = None) -> None:
|
|
53
|
+
"""Forget the sign-in an interactive or device-code profile keeps (see token_cache).
|
|
54
|
+
|
|
55
|
+
The refresh token is removed from the keychain or file; the next command signs in
|
|
56
|
+
afresh. Signing out of Entra ID itself, everywhere, is done in your account settings.
|
|
57
|
+
"""
|
|
58
|
+
runtime = get_runtime(ctx).microsoft
|
|
59
|
+
selected = runtime.profile(profile)
|
|
60
|
+
if selected.auth == "azure-cli":
|
|
61
|
+
raise LdoError(
|
|
62
|
+
f"profile {selected.name!r} uses the Azure CLI's sign-in, which it keeps itself",
|
|
63
|
+
hint="run 'az logout', or 'az account clear' to forget every account",
|
|
64
|
+
)
|
|
65
|
+
if selected.token_cache == "memory":
|
|
66
|
+
render.note(f'profile {selected.name!r} keeps no sign-in (token_cache = "memory")')
|
|
67
|
+
return
|
|
68
|
+
if runtime.sign_out(selected):
|
|
69
|
+
render.note(f"Forgot the sign-in kept for {selected.name} ({selected.token_cache}).")
|
|
70
|
+
else:
|
|
71
|
+
render.note(f"No sign-in was kept for {selected.name}.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def token(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
resource: Annotated[
|
|
77
|
+
str,
|
|
78
|
+
typer.Argument(help="graph, mde, arm, loganalytics, keyvault, or a resource URL."),
|
|
79
|
+
],
|
|
80
|
+
profile: ProfileOption = None,
|
|
81
|
+
raw: Annotated[
|
|
82
|
+
bool, typer.Option("--raw", help="Print only the token, for piping. Still checked first.")
|
|
83
|
+
] = False,
|
|
84
|
+
require: RequireOption = None,
|
|
85
|
+
strict: StrictOption = False,
|
|
86
|
+
all_claims: AllClaimsOption = False,
|
|
87
|
+
output: OutputOption = Output.TABLE,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Get an access token with the profile's credential and check its claims.
|
|
90
|
+
|
|
91
|
+
Exits 1 when a check fails (or warns, with --strict). The token itself is printed
|
|
92
|
+
only with --raw.
|
|
93
|
+
"""
|
|
94
|
+
runtime = get_runtime(ctx).microsoft
|
|
95
|
+
selected = runtime.profile(profile)
|
|
96
|
+
target = resolve_resource(resource, selected.cloud)
|
|
97
|
+
access = runtime.tokens(selected).get_token(target.url, selected.tenant_id)
|
|
98
|
+
decoded = decode_token(access.token)
|
|
99
|
+
checks = validate_token(
|
|
100
|
+
decoded,
|
|
101
|
+
resource=target,
|
|
102
|
+
tenant_id=selected.tenant_id,
|
|
103
|
+
required=require or (),
|
|
104
|
+
requirements=REQUIREMENTS,
|
|
105
|
+
)
|
|
106
|
+
ok = passed(checks, strict=strict)
|
|
107
|
+
if raw:
|
|
108
|
+
if not ok:
|
|
109
|
+
render.checks_to_stderr(checks)
|
|
110
|
+
raise typer.Exit(ERROR)
|
|
111
|
+
render.echo(access.token)
|
|
112
|
+
return
|
|
113
|
+
_report(
|
|
114
|
+
decoded,
|
|
115
|
+
checks,
|
|
116
|
+
heading=f"{target.key} token for profile {selected.name} ({selected.auth})",
|
|
117
|
+
all_claims=all_claims,
|
|
118
|
+
output=output,
|
|
119
|
+
ok=ok,
|
|
120
|
+
)
|
|
121
|
+
if not ok:
|
|
122
|
+
raise typer.Exit(ERROR)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def inspect_token(
|
|
126
|
+
value: Annotated[
|
|
127
|
+
str | None,
|
|
128
|
+
typer.Argument(
|
|
129
|
+
metavar="[TOKEN]",
|
|
130
|
+
help="Token to inspect. Omit or pass - to read stdin (keeps it out of shell history).",
|
|
131
|
+
show_default=False,
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
134
|
+
resource: Annotated[
|
|
135
|
+
str | None,
|
|
136
|
+
typer.Option("--resource", help="Expected API: graph, mde, arm, ..., or a URL."),
|
|
137
|
+
] = None,
|
|
138
|
+
tenant: Annotated[str | None, typer.Option("--tenant", help="Expected tenant id.")] = None,
|
|
139
|
+
require: RequireOption = None,
|
|
140
|
+
strict: StrictOption = False,
|
|
141
|
+
all_claims: AllClaimsOption = False,
|
|
142
|
+
output: OutputOption = Output.TABLE,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Decode a token you already have and check its claims. Nothing is sent anywhere."""
|
|
145
|
+
if value is None or value == "-":
|
|
146
|
+
if sys.stdin.isatty():
|
|
147
|
+
raise LdoError(
|
|
148
|
+
"no token given",
|
|
149
|
+
hint=f"pipe one in, e.g. 'pbpaste | {brand.COMMAND} entra inspect-token'",
|
|
150
|
+
)
|
|
151
|
+
value = sys.stdin.read()
|
|
152
|
+
if not value.strip():
|
|
153
|
+
raise LdoError("no token on stdin")
|
|
154
|
+
decoded = decode_token(value)
|
|
155
|
+
checks = validate_token(
|
|
156
|
+
decoded,
|
|
157
|
+
resource=resolve_resource(resource) if resource else None,
|
|
158
|
+
tenant_id=tenant,
|
|
159
|
+
required=require or (),
|
|
160
|
+
requirements=REQUIREMENTS,
|
|
161
|
+
)
|
|
162
|
+
ok = passed(checks, strict=strict)
|
|
163
|
+
_report(decoded, checks, heading="token", all_claims=all_claims, output=output, ok=ok)
|
|
164
|
+
if not ok:
|
|
165
|
+
raise typer.Exit(ERROR)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _report(
|
|
169
|
+
decoded: DecodedToken,
|
|
170
|
+
checks: list[Check],
|
|
171
|
+
*,
|
|
172
|
+
heading: str,
|
|
173
|
+
all_claims: bool,
|
|
174
|
+
output: Output,
|
|
175
|
+
ok: bool,
|
|
176
|
+
) -> None:
|
|
177
|
+
if output is Output.JSON:
|
|
178
|
+
render.print_json(
|
|
179
|
+
{
|
|
180
|
+
"valid": ok,
|
|
181
|
+
"checks": [asdict(check) for check in checks],
|
|
182
|
+
"header": dict(decoded.header),
|
|
183
|
+
"claims": dict(decoded.claims),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
if output is Output.CSV:
|
|
188
|
+
render.emit(
|
|
189
|
+
output,
|
|
190
|
+
["RESULT", "CHECK", "DETAIL"],
|
|
191
|
+
[[check.status, check.name, check.detail] for check in checks],
|
|
192
|
+
None,
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
app_name = str(decoded.claims.get("app_displayname") or "")
|
|
196
|
+
render.echo(render.title(heading))
|
|
197
|
+
render.echo(
|
|
198
|
+
render.pairs(
|
|
199
|
+
[
|
|
200
|
+
("Audience", ", ".join(decoded.audiences)),
|
|
201
|
+
("Tenant", decoded.tenant_id),
|
|
202
|
+
("Issuer", decoded.issuer),
|
|
203
|
+
("Principal", f"{decoded.principal} ({decoded.identity_type})"),
|
|
204
|
+
("Client app", f"{app_name} {decoded.app_id}".strip()),
|
|
205
|
+
("Scopes (scp)", " ".join(decoded.scopes)),
|
|
206
|
+
("Roles", " ".join(decoded.roles)),
|
|
207
|
+
("Issued", render.when(decoded.issued_at)),
|
|
208
|
+
("Expires", render.when(decoded.expires_at)),
|
|
209
|
+
]
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
if all_claims:
|
|
213
|
+
render.echo()
|
|
214
|
+
render.echo(
|
|
215
|
+
render.pairs(
|
|
216
|
+
(name, value if isinstance(value, str) else json.dumps(value))
|
|
217
|
+
for name, value in sorted(decoded.claims.items())
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
render.echo()
|
|
221
|
+
render.echo(render.checks_table(checks))
|
|
222
|
+
render.note("Signature not verified: this checks the claims only.")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The welcome command: the banner, where the config lives, and what to run next."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from libre_devops_helpers import __version__
|
|
6
|
+
from libre_devops_helpers.cli import render
|
|
7
|
+
from libre_devops_helpers.cli.options import get_runtime
|
|
8
|
+
from libre_devops_helpers.core import brand
|
|
9
|
+
from libre_devops_helpers.core.config import default_config_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
app.command("welcome")(welcome)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def welcome(ctx: typer.Context) -> None:
|
|
17
|
+
"""Say hello: the banner, the version, the config file, and the next step."""
|
|
18
|
+
render.banner(force=True)
|
|
19
|
+
runtime = get_runtime(ctx)
|
|
20
|
+
path = runtime.config_path or default_config_path()
|
|
21
|
+
file = runtime.optional_config_file()
|
|
22
|
+
render.echo(
|
|
23
|
+
render.pairs(
|
|
24
|
+
[
|
|
25
|
+
("Version", f"{brand.COMMAND} {__version__}"),
|
|
26
|
+
("Config", f"{path}" + ("" if file else " (not created yet)")),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
render.echo()
|
|
31
|
+
if file is None:
|
|
32
|
+
steps = [
|
|
33
|
+
(brand.command("config init"), "write a config file to fill in"),
|
|
34
|
+
(brand.command("--help"), "see every command"),
|
|
35
|
+
]
|
|
36
|
+
else:
|
|
37
|
+
steps = [
|
|
38
|
+
(brand.command("profiles"), "check your profiles and sign-ins"),
|
|
39
|
+
(brand.command("devices check --help"), "check devices across Entra and Defender"),
|
|
40
|
+
(brand.command("--help"), "see every command"),
|
|
41
|
+
]
|
|
42
|
+
render.echo(render.title("Next"))
|
|
43
|
+
render.echo(render.pairs((command.strip("'"), purpose) for command, purpose in steps))
|