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,381 @@
|
|
|
1
|
+
"""PIM commands: eligible and active roles, requests, approvals and role settings.
|
|
2
|
+
|
|
3
|
+
Each command covers three areas: Azure resource roles (ARM), Entra roles and PIM for
|
|
4
|
+
Groups (Graph). An area that cannot be read (no P2 licence, a token without the scope) is
|
|
5
|
+
reported as a warning and the others still show; the command fails only when every area
|
|
6
|
+
it was asked for fails.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Annotated, TypeVar
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.cli import render
|
|
16
|
+
from libre_devops_helpers.cli.exits import ERROR
|
|
17
|
+
from libre_devops_helpers.cli.options import OutputOption, ProfileOption, get_runtime
|
|
18
|
+
from libre_devops_helpers.cli.render import Output
|
|
19
|
+
from libre_devops_helpers.cli.runtime import MicrosoftRuntime
|
|
20
|
+
from libre_devops_helpers.core import brand
|
|
21
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
22
|
+
from libre_devops_helpers.core.util import is_guid
|
|
23
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
24
|
+
from libre_devops_helpers.microsoft.pim import AREAS, Area, PimAssignment, PimRequest
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
pim_app = typer.Typer(
|
|
29
|
+
rich_markup_mode="markdown",
|
|
30
|
+
help="Privileged Identity Management: eligible and active roles, requests, approvals.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
AzureOption = Annotated[bool, typer.Option("--azure", help="Azure resource roles.")]
|
|
35
|
+
EntraOption = Annotated[bool, typer.Option("--entra", help="Entra (directory) roles.")]
|
|
36
|
+
GroupsOption = Annotated[bool, typer.Option("--groups", help="PIM for Groups.")]
|
|
37
|
+
UserOption = Annotated[
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--user",
|
|
41
|
+
"-u",
|
|
42
|
+
help="Someone else's (UPN, name or object id). Default: the signed-in user.",
|
|
43
|
+
show_default=False,
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
SubscriptionOption = Annotated[
|
|
47
|
+
list[str] | None,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--subscription",
|
|
50
|
+
"-s",
|
|
51
|
+
help="With --user: subscriptions to search for Azure roles. Default: all in the tenant.",
|
|
52
|
+
show_default=False,
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register(app: typer.Typer) -> None:
|
|
58
|
+
app.add_typer(pim_app, name="pim")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _areas(azure: bool, entra: bool, groups: bool) -> list[Area]:
|
|
62
|
+
chosen = [area for area, wanted in zip(AREAS, (azure, entra, groups), strict=True) if wanted]
|
|
63
|
+
return chosen or list(AREAS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _gather(areas: list[Area], calls: dict[Area, Callable[[], list[T]]]) -> list[T]:
|
|
67
|
+
"""Run each area's call; warn about the ones that fail, and fail only if all do."""
|
|
68
|
+
found: list[T] = []
|
|
69
|
+
failed = 0
|
|
70
|
+
for area in areas:
|
|
71
|
+
try:
|
|
72
|
+
found.extend(calls[area]())
|
|
73
|
+
except LdoError as exc:
|
|
74
|
+
failed += 1
|
|
75
|
+
render.warn(f"{area}: {exc}")
|
|
76
|
+
hint = _hint(area, str(exc)) or exc.hint
|
|
77
|
+
if hint:
|
|
78
|
+
render.note(f"hint: {hint}")
|
|
79
|
+
if failed and failed == len(areas):
|
|
80
|
+
raise typer.Exit(ERROR)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _hint(area: Area, text: str) -> str | None:
|
|
85
|
+
if "AadPremiumLicenseRequired" in text or "TenantNotOnboarded" in text:
|
|
86
|
+
return "PIM needs Microsoft Entra ID P2 or ID Governance in the tenant"
|
|
87
|
+
if area != "azure" and ("PermissionScopeNotGranted" in text or "HTTP 403" in text):
|
|
88
|
+
return (
|
|
89
|
+
"these views need a token with the PIM scopes; the Azure CLI's never has them. Use a "
|
|
90
|
+
'profile with auth = "interactive" or "device-code" '
|
|
91
|
+
f"(see {brand.docs('authentication')})"
|
|
92
|
+
)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _principal(runtime: MicrosoftRuntime, profile: Profile, user: str | None) -> str | None:
|
|
97
|
+
if user is None:
|
|
98
|
+
return None
|
|
99
|
+
if is_guid(user):
|
|
100
|
+
return user.strip().lower()
|
|
101
|
+
return runtime.entra(profile).find_principal(user).id
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scopes(runtime: MicrosoftRuntime, profile: Profile, chosen: list[str] | None) -> list[str]:
|
|
105
|
+
return [f"/subscriptions/{item}" for item in runtime.subscription_ids(profile, chosen)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _duration(value: str) -> str:
|
|
109
|
+
"""ISO 8601 durations as people say them: PT8H -> 8h, P1DT2H -> 1d 2h."""
|
|
110
|
+
match = re.fullmatch(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", value.strip())
|
|
111
|
+
if not value or not match or not any(match.groups()):
|
|
112
|
+
return value or "-"
|
|
113
|
+
return " ".join(f"{n}{unit}" for n, unit in zip(match.groups(), "dhms", strict=True) if n)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _assignment_rows(items: list[PimAssignment], active: bool) -> list[list[render.Cell]]:
|
|
117
|
+
rows: list[list[render.Cell]] = []
|
|
118
|
+
for item in items:
|
|
119
|
+
ends: render.Cell = (
|
|
120
|
+
("permanent", "yellow") if item.permanent and active else render.when(item.ends)
|
|
121
|
+
)
|
|
122
|
+
row: list[render.Cell] = [item.area, item.role, item.scope, item.member_type or "-"]
|
|
123
|
+
if active:
|
|
124
|
+
row.append(("activated", "green") if item.activated else item.assignment_type or "-")
|
|
125
|
+
rows.extend([[*row, render.when(item.starts), ends]])
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _assignment_records(items: list[PimAssignment]) -> list[dict[str, object]]:
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
"area": item.area,
|
|
133
|
+
"role": item.role,
|
|
134
|
+
"scope": item.scope,
|
|
135
|
+
"member_type": item.member_type,
|
|
136
|
+
"assignment_type": item.assignment_type,
|
|
137
|
+
"permanent": item.permanent,
|
|
138
|
+
"raw": dict(item.raw),
|
|
139
|
+
}
|
|
140
|
+
for item in items
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pim_app.command("eligible")
|
|
145
|
+
def eligible(
|
|
146
|
+
ctx: typer.Context,
|
|
147
|
+
azure: AzureOption = False,
|
|
148
|
+
entra: EntraOption = False,
|
|
149
|
+
groups: GroupsOption = False,
|
|
150
|
+
user: UserOption = None,
|
|
151
|
+
subscription: SubscriptionOption = None,
|
|
152
|
+
profile: ProfileOption = None,
|
|
153
|
+
output: OutputOption = Output.TABLE,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""List the roles you (or --user) can activate through PIM."""
|
|
156
|
+
runtime = get_runtime(ctx).microsoft
|
|
157
|
+
selected = runtime.profile(profile)
|
|
158
|
+
principal = _principal(runtime, selected, user)
|
|
159
|
+
areas = _areas(azure, entra, groups)
|
|
160
|
+
items = _gather(
|
|
161
|
+
areas,
|
|
162
|
+
{
|
|
163
|
+
"azure": lambda: runtime.azure_pim(selected).eligible(
|
|
164
|
+
principal_id=principal,
|
|
165
|
+
scopes=_scopes(runtime, selected, subscription) if principal else (),
|
|
166
|
+
),
|
|
167
|
+
"entra": lambda: runtime.graph_pim(selected).role_eligible(principal_id=principal),
|
|
168
|
+
"groups": lambda: runtime.graph_pim(selected).group_eligible(principal_id=principal),
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
render.emit(
|
|
172
|
+
output,
|
|
173
|
+
["AREA", "ROLE", "SCOPE", "MEMBERSHIP", "FROM", "UNTIL"],
|
|
174
|
+
_assignment_rows(items, active=False),
|
|
175
|
+
_assignment_records(items),
|
|
176
|
+
)
|
|
177
|
+
render.note(f"{len(items)} eligible role(s)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pim_app.command("active")
|
|
181
|
+
def active(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
azure: AzureOption = False,
|
|
184
|
+
entra: EntraOption = False,
|
|
185
|
+
groups: GroupsOption = False,
|
|
186
|
+
user: UserOption = None,
|
|
187
|
+
subscription: SubscriptionOption = None,
|
|
188
|
+
permanent_only: Annotated[
|
|
189
|
+
bool,
|
|
190
|
+
typer.Option("--permanent-only", help="Only standing access: roles with no end date."),
|
|
191
|
+
] = False,
|
|
192
|
+
profile: ProfileOption = None,
|
|
193
|
+
output: OutputOption = Output.TABLE,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""List the roles you (or --user) hold now: activated through PIM, or permanent."""
|
|
196
|
+
runtime = get_runtime(ctx).microsoft
|
|
197
|
+
selected = runtime.profile(profile)
|
|
198
|
+
principal = _principal(runtime, selected, user)
|
|
199
|
+
items = _gather(
|
|
200
|
+
_areas(azure, entra, groups),
|
|
201
|
+
{
|
|
202
|
+
"azure": lambda: runtime.azure_pim(selected).active(
|
|
203
|
+
principal_id=principal,
|
|
204
|
+
scopes=_scopes(runtime, selected, subscription) if principal else (),
|
|
205
|
+
),
|
|
206
|
+
"entra": lambda: runtime.graph_pim(selected).role_active(principal_id=principal),
|
|
207
|
+
"groups": lambda: runtime.graph_pim(selected).group_active(principal_id=principal),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
if permanent_only:
|
|
211
|
+
items = [item for item in items if item.permanent]
|
|
212
|
+
render.emit(
|
|
213
|
+
output,
|
|
214
|
+
["AREA", "ROLE", "SCOPE", "MEMBERSHIP", "TYPE", "FROM", "UNTIL"],
|
|
215
|
+
_assignment_rows(items, active=True),
|
|
216
|
+
_assignment_records(items),
|
|
217
|
+
)
|
|
218
|
+
standing = sum(1 for item in items if item.permanent)
|
|
219
|
+
render.note(f"{len(items)} active role(s), {standing} of them permanent")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _request_rows(items: list[PimRequest]) -> list[list[render.Cell]]:
|
|
223
|
+
colours = {"pendingapproval": "yellow", "denied": "red", "provisioned": "green"}
|
|
224
|
+
return [
|
|
225
|
+
[
|
|
226
|
+
item.area,
|
|
227
|
+
render.when(item.created),
|
|
228
|
+
item.action,
|
|
229
|
+
(item.status, colours.get(item.status.casefold())),
|
|
230
|
+
item.role,
|
|
231
|
+
item.scope,
|
|
232
|
+
_duration(item.duration) if item.duration else render.when(item.ends),
|
|
233
|
+
item.justification,
|
|
234
|
+
]
|
|
235
|
+
for item in items
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _request_records(items: list[PimRequest]) -> list[dict[str, object]]:
|
|
240
|
+
return [
|
|
241
|
+
{"area": item.area, "status": item.status, "role": item.role, "raw": dict(item.raw)}
|
|
242
|
+
for item in items
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_REQUEST_HEADERS = ["AREA", "CREATED", "ACTION", "STATUS", "ROLE", "SCOPE", "FOR", "JUSTIFICATION"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@pim_app.command("requests")
|
|
250
|
+
def requests_(
|
|
251
|
+
ctx: typer.Context,
|
|
252
|
+
azure: AzureOption = False,
|
|
253
|
+
entra: EntraOption = False,
|
|
254
|
+
groups: GroupsOption = False,
|
|
255
|
+
user: UserOption = None,
|
|
256
|
+
subscription: SubscriptionOption = None,
|
|
257
|
+
pending: Annotated[
|
|
258
|
+
bool, typer.Option("--pending", help="Only requests still waiting.")
|
|
259
|
+
] = False,
|
|
260
|
+
profile: ProfileOption = None,
|
|
261
|
+
output: OutputOption = Output.TABLE,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""List PIM requests you (or --user) made, newest first, and where each has got to."""
|
|
264
|
+
runtime = get_runtime(ctx).microsoft
|
|
265
|
+
selected = runtime.profile(profile)
|
|
266
|
+
principal = _principal(runtime, selected, user)
|
|
267
|
+
items = _gather(
|
|
268
|
+
_areas(azure, entra, groups),
|
|
269
|
+
{
|
|
270
|
+
"azure": lambda: runtime.azure_pim(selected).requests(
|
|
271
|
+
principal_id=principal,
|
|
272
|
+
scopes=_scopes(runtime, selected, subscription) if principal else (),
|
|
273
|
+
),
|
|
274
|
+
"entra": lambda: runtime.graph_pim(selected).role_requests(principal_id=principal),
|
|
275
|
+
"groups": lambda: runtime.graph_pim(selected).group_requests(principal_id=principal),
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
if pending:
|
|
279
|
+
items = [item for item in items if item.pending]
|
|
280
|
+
render.emit(output, _REQUEST_HEADERS, _request_rows(items), _request_records(items))
|
|
281
|
+
render.note(f"{len(items)} request(s)")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pim_app.command("approvals")
|
|
285
|
+
def approvals(
|
|
286
|
+
ctx: typer.Context,
|
|
287
|
+
azure: AzureOption = False,
|
|
288
|
+
entra: EntraOption = False,
|
|
289
|
+
groups: GroupsOption = False,
|
|
290
|
+
profile: ProfileOption = None,
|
|
291
|
+
output: OutputOption = Output.TABLE,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""List PIM requests waiting for you to approve them."""
|
|
294
|
+
runtime = get_runtime(ctx).microsoft
|
|
295
|
+
selected = runtime.profile(profile)
|
|
296
|
+
items = _gather(
|
|
297
|
+
_areas(azure, entra, groups),
|
|
298
|
+
{
|
|
299
|
+
"azure": lambda: runtime.azure_pim(selected).requests(approver=True),
|
|
300
|
+
"entra": lambda: runtime.graph_pim(selected).role_requests(approver=True),
|
|
301
|
+
"groups": lambda: runtime.graph_pim(selected).group_requests(approver=True),
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
items = [item for item in items if item.pending]
|
|
305
|
+
render.emit(output, _REQUEST_HEADERS, _request_rows(items), _request_records(items))
|
|
306
|
+
render.note(
|
|
307
|
+
f"{len(items)} request(s) waiting for you; approve them in the portal or with "
|
|
308
|
+
f"your usual tooling ({brand.COMMAND} only reads)"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@pim_app.command("settings")
|
|
313
|
+
def settings(
|
|
314
|
+
ctx: typer.Context,
|
|
315
|
+
role: Annotated[
|
|
316
|
+
str | None,
|
|
317
|
+
typer.Argument(help="Role name, e.g. Owner or 'Global Administrator'.", show_default=False),
|
|
318
|
+
] = None,
|
|
319
|
+
scope: Annotated[
|
|
320
|
+
str | None,
|
|
321
|
+
typer.Option(
|
|
322
|
+
"--scope",
|
|
323
|
+
help="An Azure scope, e.g. /subscriptions/SUBSCRIPTION_ID, for an Azure role.",
|
|
324
|
+
),
|
|
325
|
+
] = None,
|
|
326
|
+
group: Annotated[
|
|
327
|
+
str | None, typer.Option("--group", help="A PIM for Groups group (name or object id).")
|
|
328
|
+
] = None,
|
|
329
|
+
owner: Annotated[
|
|
330
|
+
bool, typer.Option("--owner", help="With --group: the owner settings.")
|
|
331
|
+
] = False,
|
|
332
|
+
profile: ProfileOption = None,
|
|
333
|
+
output: OutputOption = Output.TABLE,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Show what activating a role takes: duration, MFA, justification, approval, approvers.
|
|
336
|
+
|
|
337
|
+
An Azure role needs --scope; an Entra role needs only its name; a group needs --group.
|
|
338
|
+
"""
|
|
339
|
+
runtime = get_runtime(ctx).microsoft
|
|
340
|
+
selected = runtime.profile(profile)
|
|
341
|
+
area: Area = "groups" if group else "azure" if scope else "entra"
|
|
342
|
+
try:
|
|
343
|
+
if group:
|
|
344
|
+
group_id = group if is_guid(group) else runtime.entra(selected).get_group(group).id
|
|
345
|
+
found = runtime.graph_pim(selected).group_settings(
|
|
346
|
+
group_id, "owner" if owner else "member"
|
|
347
|
+
)
|
|
348
|
+
elif role is None:
|
|
349
|
+
raise typer.BadParameter("name a role, or pass --group")
|
|
350
|
+
elif scope:
|
|
351
|
+
found = runtime.azure_pim(selected).settings(role, scope)
|
|
352
|
+
else:
|
|
353
|
+
found = runtime.graph_pim(selected).role_settings(role)
|
|
354
|
+
except LdoError as exc:
|
|
355
|
+
raise LdoError(str(exc), hint=_hint(area, str(exc)) or exc.hint) from None
|
|
356
|
+
pairs = [
|
|
357
|
+
("Role", found.role),
|
|
358
|
+
("Area", found.area),
|
|
359
|
+
("Scope", found.scope),
|
|
360
|
+
("Longest activation", _duration(found.max_activation)),
|
|
361
|
+
("Needs MFA", render.yes_no(found.requires_mfa)),
|
|
362
|
+
("Needs justification", render.yes_no(found.requires_justification)),
|
|
363
|
+
("Needs a ticket", render.yes_no(found.requires_ticket)),
|
|
364
|
+
("Needs approval", render.yes_no(found.requires_approval)),
|
|
365
|
+
("Approvers", ", ".join(found.approvers)),
|
|
366
|
+
("Authentication context", found.authentication_context),
|
|
367
|
+
("Eligible assignments", _duration(found.eligible_expiry)),
|
|
368
|
+
("Active assignments", _duration(found.active_expiry)),
|
|
369
|
+
]
|
|
370
|
+
if output is Output.TABLE:
|
|
371
|
+
render.echo(render.pairs(pairs))
|
|
372
|
+
else:
|
|
373
|
+
render.emit(
|
|
374
|
+
output,
|
|
375
|
+
[label for label, _ in pairs],
|
|
376
|
+
[[value for _, value in pairs]],
|
|
377
|
+
{
|
|
378
|
+
**{label: value for label, value in pairs},
|
|
379
|
+
"rules": [dict(rule) for rule in found.raw],
|
|
380
|
+
},
|
|
381
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The json command: pretty-print JSON from anywhere, in colour on a terminal, or as YAML.
|
|
2
|
+
|
|
3
|
+
For JSON that did not come from this tool: 'az rest', curl, a file. It reads one JSON
|
|
4
|
+
document, or JSON Lines (one document per line, as OTLP logs and many APIs write them).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.cli import render
|
|
15
|
+
from libre_devops_helpers.core import brand, yaml_text
|
|
16
|
+
from libre_devops_helpers.core.errors import InputError
|
|
17
|
+
|
|
18
|
+
# YAML's colours follow JSON's: keys, strings, numbers, booleans, null.
|
|
19
|
+
_YAML_COLOURS = {
|
|
20
|
+
"key": (75, True),
|
|
21
|
+
"string": (114, False),
|
|
22
|
+
"number": (215, False),
|
|
23
|
+
"bool": (176, False),
|
|
24
|
+
"null": (244, False),
|
|
25
|
+
"punct": (None, False),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(app: typer.Typer) -> None:
|
|
30
|
+
app.command("json")(pretty)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pretty(
|
|
34
|
+
file: Annotated[
|
|
35
|
+
Path | None,
|
|
36
|
+
typer.Argument(
|
|
37
|
+
metavar="[FILE]",
|
|
38
|
+
help="A JSON or JSON Lines file. Omit, or pass -, to read stdin.",
|
|
39
|
+
show_default=False,
|
|
40
|
+
),
|
|
41
|
+
] = None,
|
|
42
|
+
sort_keys: Annotated[bool, typer.Option("--sort-keys", help="Sort object keys.")] = False,
|
|
43
|
+
compact: Annotated[
|
|
44
|
+
bool, typer.Option("--compact", "-c", help="One line per document, no spaces.")
|
|
45
|
+
] = False,
|
|
46
|
+
indent: Annotated[int, typer.Option("--indent", min=0, max=8, help="Spaces per level.")] = 2,
|
|
47
|
+
yaml: Annotated[bool, typer.Option("--yaml", help="Write it as YAML instead.")] = False,
|
|
48
|
+
colour: Annotated[
|
|
49
|
+
bool | None,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--colour/--no-colour",
|
|
52
|
+
help="Colour it, e.g. for less -R. Default: on a terminal, unless NO_COLOR is set.",
|
|
53
|
+
show_default=False,
|
|
54
|
+
),
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Pretty-print JSON from stdin or a file, in colour on a terminal, or as YAML.
|
|
58
|
+
|
|
59
|
+
For JSON from anywhere, e.g. az rest --url ... | ldo json. It reads one document, or
|
|
60
|
+
JSON Lines, and shows it indented, with keys, strings, numbers and brackets coloured
|
|
61
|
+
(brackets by how deeply they nest). Piped on, it stays plain. --yaml writes YAML.
|
|
62
|
+
"""
|
|
63
|
+
documents = _documents(_read(file))
|
|
64
|
+
painted = render.colour_wanted() if colour is None else colour
|
|
65
|
+
if yaml:
|
|
66
|
+
if compact:
|
|
67
|
+
raise InputError("--compact is for JSON; YAML has no one-line form here")
|
|
68
|
+
paint = _paint_yaml if painted else None
|
|
69
|
+
for number, document in enumerate(documents):
|
|
70
|
+
if number:
|
|
71
|
+
typer.echo(typer.style("---", dim=True) if painted else "---", color=painted)
|
|
72
|
+
data = _sorted(document) if sort_keys else document
|
|
73
|
+
typer.echo(
|
|
74
|
+
yaml_text.dumps(data, indent=indent or 2, paint=paint), nl=False, color=painted
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
spacing = None if compact else indent
|
|
78
|
+
for document in documents:
|
|
79
|
+
if painted:
|
|
80
|
+
text = render.colour_json(document, indent=spacing, sort_keys=sort_keys)
|
|
81
|
+
else:
|
|
82
|
+
text = json.dumps(
|
|
83
|
+
document,
|
|
84
|
+
indent=spacing,
|
|
85
|
+
sort_keys=sort_keys,
|
|
86
|
+
ensure_ascii=False,
|
|
87
|
+
separators=(",", ":") if compact else None,
|
|
88
|
+
)
|
|
89
|
+
typer.echo(text, color=painted)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _paint_yaml(kind: str, text: str) -> str:
|
|
93
|
+
colour, bold = _YAML_COLOURS[kind]
|
|
94
|
+
return typer.style(text, fg=colour, bold=bold) if colour is not None else text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sorted(value: Any) -> Any:
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
return {key: _sorted(value[key]) for key in sorted(value)}
|
|
100
|
+
if isinstance(value, list):
|
|
101
|
+
return [_sorted(item) for item in value]
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read(file: Path | None) -> str:
|
|
106
|
+
if file is not None and str(file) != "-":
|
|
107
|
+
try:
|
|
108
|
+
return file.read_text(encoding="utf-8-sig")
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
raise InputError(f"no such file: {file}") from None
|
|
111
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
112
|
+
raise InputError(f"cannot read {file}: {exc}") from None
|
|
113
|
+
if sys.stdin.isatty():
|
|
114
|
+
raise InputError(
|
|
115
|
+
"no JSON to show", hint=f"pipe some in, e.g. az rest --url ... | {brand.COMMAND} json"
|
|
116
|
+
)
|
|
117
|
+
return sys.stdin.read()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _documents(text: str) -> list[Any]:
|
|
121
|
+
"""One JSON document, or each line of JSON Lines. A bad one says where it broke."""
|
|
122
|
+
if not text.strip():
|
|
123
|
+
raise InputError("the input is empty")
|
|
124
|
+
try:
|
|
125
|
+
return [json.loads(text)]
|
|
126
|
+
except json.JSONDecodeError as whole:
|
|
127
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
128
|
+
if len(lines) < 2:
|
|
129
|
+
raise _not_json(whole) from None
|
|
130
|
+
documents = []
|
|
131
|
+
for line in lines:
|
|
132
|
+
try:
|
|
133
|
+
documents.append(json.loads(line))
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
# Neither one document nor JSON Lines: report the document's own error.
|
|
136
|
+
raise _not_json(whole) from None
|
|
137
|
+
return documents
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _not_json(error: json.JSONDecodeError) -> InputError:
|
|
141
|
+
return InputError(f"not JSON: {error.msg} at line {error.lineno}, column {error.colno}")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The profiles command: every profile the config file defines, for every vendor."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.cli import render
|
|
8
|
+
from libre_devops_helpers.cli.options import OutputOption, get_runtime
|
|
9
|
+
from libre_devops_helpers.cli.render import Output
|
|
10
|
+
from libre_devops_helpers.cli.runtime import Runtime
|
|
11
|
+
from libre_devops_helpers.core import brand
|
|
12
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
13
|
+
from libre_devops_helpers.microsoft.azcli import find_account, match_profile
|
|
14
|
+
from libre_devops_helpers.microsoft.process import AzCliError
|
|
15
|
+
from libre_devops_helpers.servicenow import OAuthCredential
|
|
16
|
+
from libre_devops_helpers.servicenow import Profile as ServiceNowProfile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: typer.Typer) -> None:
|
|
20
|
+
app.command("profiles")(profiles)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def profiles(ctx: typer.Context, output: OutputOption = Output.TABLE) -> None:
|
|
24
|
+
"""List configured profiles, which is active, and whether each can sign in."""
|
|
25
|
+
runtime = get_runtime(ctx)
|
|
26
|
+
runtime.config_file()
|
|
27
|
+
rows, records = _microsoft(runtime)
|
|
28
|
+
snow_rows, snow_records = _servicenow(runtime)
|
|
29
|
+
rows += snow_rows
|
|
30
|
+
records += snow_records
|
|
31
|
+
render.emit(
|
|
32
|
+
output,
|
|
33
|
+
["VENDOR", "", "PROFILE", "TARGET", "AUTH", "SIGNED IN", "DESCRIPTION"],
|
|
34
|
+
rows,
|
|
35
|
+
records,
|
|
36
|
+
)
|
|
37
|
+
if not rows:
|
|
38
|
+
render.warn(f"no profiles configured; {brand.command('config init')} writes a template")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _microsoft(runtime: Runtime) -> tuple[list[list[render.Cell]], list[dict[str, Any]]]:
|
|
42
|
+
ms = runtime.microsoft
|
|
43
|
+
config = ms.optional_config()
|
|
44
|
+
if config is None:
|
|
45
|
+
return [], []
|
|
46
|
+
try:
|
|
47
|
+
accounts = ms.az.accounts()
|
|
48
|
+
except AzCliError as exc:
|
|
49
|
+
render.warn(f"cannot read Azure CLI accounts: {exc}")
|
|
50
|
+
accounts = None
|
|
51
|
+
active_account = next((account for account in accounts or [] if account.is_default), None)
|
|
52
|
+
active = match_profile(config.profiles.values(), active_account) if active_account else None
|
|
53
|
+
|
|
54
|
+
rows: list[list[render.Cell]] = []
|
|
55
|
+
records: list[dict[str, Any]] = []
|
|
56
|
+
for profile in config.profiles.values():
|
|
57
|
+
signed_in = None if accounts is None else find_account(accounts, profile) is not None
|
|
58
|
+
is_active = active is not None and profile.name == active.name
|
|
59
|
+
is_default = profile.name == config.default_profile
|
|
60
|
+
records.append(
|
|
61
|
+
{
|
|
62
|
+
"vendor": "microsoft",
|
|
63
|
+
"name": profile.name,
|
|
64
|
+
"kind": profile.kind,
|
|
65
|
+
"tenant_id": profile.tenant_id,
|
|
66
|
+
"subscription_id": profile.subscription_id,
|
|
67
|
+
"cloud": profile.cloud.name,
|
|
68
|
+
"auth": profile.auth,
|
|
69
|
+
"description": profile.description,
|
|
70
|
+
"default": is_default,
|
|
71
|
+
"active": is_active,
|
|
72
|
+
"signed_in": signed_in,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
target = (
|
|
76
|
+
f"subscription {profile.subscription_id}"
|
|
77
|
+
if profile.subscription_id
|
|
78
|
+
else f"tenant {profile.tenant_id}"
|
|
79
|
+
)
|
|
80
|
+
if profile.cloud.name != "public":
|
|
81
|
+
target += f" ({profile.cloud.name})"
|
|
82
|
+
rows.append(
|
|
83
|
+
[
|
|
84
|
+
"microsoft",
|
|
85
|
+
("*", "green") if is_active else " ",
|
|
86
|
+
profile.name + (" (default)" if is_default else ""),
|
|
87
|
+
target,
|
|
88
|
+
profile.auth,
|
|
89
|
+
_signed_in_cell(profile.auth, signed_in),
|
|
90
|
+
profile.description,
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
placeholders = [p.name for p in config.profiles.values() if p.has_placeholder_ids]
|
|
94
|
+
if placeholders:
|
|
95
|
+
render.warn(f"placeholder ids in {', '.join(placeholders)}; edit {config.path}")
|
|
96
|
+
return rows, records
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _servicenow(runtime: Runtime) -> tuple[list[list[render.Cell]], list[dict[str, Any]]]:
|
|
100
|
+
snow = runtime.servicenow
|
|
101
|
+
config = snow.config()
|
|
102
|
+
default = config.default_profile if config else None
|
|
103
|
+
rows: list[list[render.Cell]] = []
|
|
104
|
+
records: list[dict[str, Any]] = []
|
|
105
|
+
for profile in snow.profiles():
|
|
106
|
+
signed_in = _snow_signed_in(runtime, profile)
|
|
107
|
+
method = f"oauth ({profile.sign_in})" if profile.auth == "oauth" else profile.auth
|
|
108
|
+
records.append(
|
|
109
|
+
{
|
|
110
|
+
"vendor": "servicenow",
|
|
111
|
+
"name": profile.name,
|
|
112
|
+
"instance": profile.instance,
|
|
113
|
+
"auth": profile.auth,
|
|
114
|
+
"sign_in": profile.sign_in if profile.auth == "oauth" else None,
|
|
115
|
+
"description": profile.description,
|
|
116
|
+
"default": profile.name == default,
|
|
117
|
+
"signed_in": signed_in,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
rows.append(
|
|
121
|
+
[
|
|
122
|
+
"servicenow",
|
|
123
|
+
" ",
|
|
124
|
+
profile.name + (" (default)" if profile.name == default else ""),
|
|
125
|
+
profile.host,
|
|
126
|
+
method,
|
|
127
|
+
"?" if signed_in is None else ("yes", "green") if signed_in else ("no", "yellow"),
|
|
128
|
+
profile.description,
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
return rows, records
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _snow_signed_in(runtime: Runtime, profile: ServiceNowProfile) -> bool | None:
|
|
135
|
+
"""Whether a command could sign in now, without asking: a kept sign-in, or a password."""
|
|
136
|
+
if profile.auth == "basic":
|
|
137
|
+
return bool(runtime.environ.get(profile.password_env))
|
|
138
|
+
if profile.token_cache == "keychain":
|
|
139
|
+
return None # reading the keychain can prompt; not worth it for a listing
|
|
140
|
+
try:
|
|
141
|
+
credential = runtime.servicenow.credential(profile)
|
|
142
|
+
except LdoError:
|
|
143
|
+
return False
|
|
144
|
+
return isinstance(credential, OAuthCredential) and credential.has_kept_sign_in()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _signed_in_cell(auth: str, signed_in: bool | None) -> render.Cell:
|
|
148
|
+
# Only azure-cli profiles depend on an az session; the others bring their own.
|
|
149
|
+
if auth != "azure-cli":
|
|
150
|
+
return ("n/a", None)
|
|
151
|
+
if signed_in is None:
|
|
152
|
+
return "?"
|
|
153
|
+
return ("yes", "green") if signed_in else ("no", "yellow")
|