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,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))