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,554 @@
|
|
|
1
|
+
"""Entra commands: devices, users, groups, roles, sign-ins, app credentials, CA policies."""
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from libre_devops_helpers.cli import render
|
|
10
|
+
from libre_devops_helpers.cli.exits import ATTENTION
|
|
11
|
+
from libre_devops_helpers.cli.options import (
|
|
12
|
+
ColumnOption,
|
|
13
|
+
DirectOption,
|
|
14
|
+
FromFileOption,
|
|
15
|
+
NamesArgument,
|
|
16
|
+
OutputOption,
|
|
17
|
+
ProfileOption,
|
|
18
|
+
SheetOption,
|
|
19
|
+
duration,
|
|
20
|
+
get_runtime,
|
|
21
|
+
names,
|
|
22
|
+
)
|
|
23
|
+
from libre_devops_helpers.cli.render import Output
|
|
24
|
+
from libre_devops_helpers.core.errors import NotFoundError
|
|
25
|
+
from libre_devops_helpers.core.util import candidate_names, format_duration, is_guid
|
|
26
|
+
from libre_devops_helpers.microsoft.entra import (
|
|
27
|
+
AppCredential,
|
|
28
|
+
EntraDevice,
|
|
29
|
+
EntraGroup,
|
|
30
|
+
MemberKind,
|
|
31
|
+
RoleAssignment,
|
|
32
|
+
expiring,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
entra_app = typer.Typer(
|
|
36
|
+
rich_markup_mode="markdown",
|
|
37
|
+
help="Entra ID: devices, users, groups, roles and apps.",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register(app: typer.Typer) -> None:
|
|
43
|
+
app.add_typer(entra_app, name="entra")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _group_rows(groups: list[EntraGroup]) -> list[list[render.Cell]]:
|
|
47
|
+
return [
|
|
48
|
+
[
|
|
49
|
+
group.display_name,
|
|
50
|
+
group.id,
|
|
51
|
+
"dynamic" if group.dynamic else "assigned",
|
|
52
|
+
render.yes_no(group.security_enabled),
|
|
53
|
+
]
|
|
54
|
+
for group in groups
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@entra_app.command("devices")
|
|
59
|
+
def devices(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
devices: NamesArgument = None,
|
|
62
|
+
from_file: FromFileOption = None,
|
|
63
|
+
column: ColumnOption = None,
|
|
64
|
+
sheet: SheetOption = None,
|
|
65
|
+
group: Annotated[
|
|
66
|
+
list[str] | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--group",
|
|
69
|
+
help="Also check each device is in this Entra group: its object id or display "
|
|
70
|
+
"name. Repeatable.",
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
direct: DirectOption = False,
|
|
74
|
+
workers: Annotated[
|
|
75
|
+
int, typer.Option("--workers", min=1, max=32, help="Devices looked up at once.")
|
|
76
|
+
] = 8,
|
|
77
|
+
profile: ProfileOption = None,
|
|
78
|
+
output: OutputOption = Output.TABLE,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Look devices up in Entra ID, and check they are in the groups you name.
|
|
81
|
+
|
|
82
|
+
Each device is looked up by FQDN, then by short hostname, and every registration with
|
|
83
|
+
the name is shown. --group takes a group's object id or its display name (a name two
|
|
84
|
+
groups share is refused), and counts nested membership unless --direct. Exits 3 when
|
|
85
|
+
a device is not in Entra, or not in every group.
|
|
86
|
+
"""
|
|
87
|
+
wanted = names(devices, from_file, column, sheet)
|
|
88
|
+
runtime = get_runtime(ctx).microsoft
|
|
89
|
+
selected = runtime.profile(profile)
|
|
90
|
+
entra = runtime.entra(selected)
|
|
91
|
+
# Groups first, on this thread: each is resolved and its members fetched once, and a
|
|
92
|
+
# lapsed sign-in is met here, where it can be renewed, rather than in a worker.
|
|
93
|
+
groups = [entra.get_group(ref) for ref in group or []]
|
|
94
|
+
members = {
|
|
95
|
+
found.id: frozenset(item.id for item in entra.group_devices(found, transitive=not direct))
|
|
96
|
+
for found in groups
|
|
97
|
+
}
|
|
98
|
+
first = [entra.find_devices(wanted[0])]
|
|
99
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
100
|
+
results = list(
|
|
101
|
+
zip(wanted, first + list(pool.map(entra.find_devices, wanted[1:])), strict=True)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def member(found: EntraGroup, records: list[EntraDevice]) -> bool:
|
|
105
|
+
return any(record.id in members[found.id] for record in records)
|
|
106
|
+
|
|
107
|
+
rows: list[list[render.Cell]] = []
|
|
108
|
+
for name, records in results:
|
|
109
|
+
if not records:
|
|
110
|
+
rows.append([name, ("not in Entra", "red"), "", "", "", "", "", *["" for _ in groups]])
|
|
111
|
+
continue
|
|
112
|
+
for index, record in enumerate(records):
|
|
113
|
+
rows.append(
|
|
114
|
+
[
|
|
115
|
+
name if index == 0 else (" older record", "bright_black"),
|
|
116
|
+
record.display_name,
|
|
117
|
+
f"{record.operating_system} {record.os_version}".strip(),
|
|
118
|
+
render.yes_no(record.enabled),
|
|
119
|
+
record.trust_type,
|
|
120
|
+
render.when(record.last_sign_in),
|
|
121
|
+
record.device_id,
|
|
122
|
+
*[
|
|
123
|
+
("yes", "green") if member(found, [record]) else ("no", "yellow")
|
|
124
|
+
for found in groups
|
|
125
|
+
],
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
records_out: list[dict[str, Any]] = [
|
|
129
|
+
{
|
|
130
|
+
"query": name,
|
|
131
|
+
"found": bool(records),
|
|
132
|
+
"groups": [
|
|
133
|
+
{"id": found.id, "name": found.display_name, "member": member(found, records)}
|
|
134
|
+
for found in groups
|
|
135
|
+
],
|
|
136
|
+
"devices": [dict(record.raw) for record in records],
|
|
137
|
+
}
|
|
138
|
+
for name, records in results
|
|
139
|
+
]
|
|
140
|
+
render.emit(
|
|
141
|
+
output,
|
|
142
|
+
[
|
|
143
|
+
"DEVICE",
|
|
144
|
+
"NAME",
|
|
145
|
+
"OS",
|
|
146
|
+
"ENABLED",
|
|
147
|
+
"TRUST",
|
|
148
|
+
"LAST SIGN-IN",
|
|
149
|
+
"DEVICE ID",
|
|
150
|
+
*[f"IN {found.display_name}" for found in groups],
|
|
151
|
+
],
|
|
152
|
+
rows,
|
|
153
|
+
records_out,
|
|
154
|
+
)
|
|
155
|
+
missing = sum(1 for _, records in results if not records)
|
|
156
|
+
outside = sum(
|
|
157
|
+
1 for _, records in results if records and not all(member(g, records) for g in groups)
|
|
158
|
+
)
|
|
159
|
+
note = f"{len(results) - missing} of {len(results)} in Entra"
|
|
160
|
+
if groups:
|
|
161
|
+
note += f", {len(results) - missing - outside} in every group"
|
|
162
|
+
render.note(note + f" (profile {selected.name})")
|
|
163
|
+
if missing or outside:
|
|
164
|
+
raise typer.Exit(ATTENTION)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@entra_app.command("device-groups")
|
|
168
|
+
def device_groups(
|
|
169
|
+
ctx: typer.Context,
|
|
170
|
+
device: Annotated[str, typer.Argument(help="Device name: FQDN or short hostname.")],
|
|
171
|
+
profile: ProfileOption = None,
|
|
172
|
+
direct: DirectOption = False,
|
|
173
|
+
output: OutputOption = Output.TABLE,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""List the Entra groups a device belongs to."""
|
|
176
|
+
runtime = get_runtime(ctx).microsoft
|
|
177
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
178
|
+
devices = entra.find_devices(device)
|
|
179
|
+
if not devices:
|
|
180
|
+
tried = " or ".join(repr(name) for name in candidate_names(device))
|
|
181
|
+
raise NotFoundError(f"no Entra device is named {tried}")
|
|
182
|
+
results = [(found, entra.device_groups(found, transitive=not direct)) for found in devices]
|
|
183
|
+
|
|
184
|
+
if output is not Output.TABLE:
|
|
185
|
+
render.emit(
|
|
186
|
+
output,
|
|
187
|
+
["DEVICE", "DEVICE OBJECT ID", "GROUP", "GROUP OBJECT ID", "MEMBERSHIP", "SECURITY"],
|
|
188
|
+
[
|
|
189
|
+
[found.display_name, found.id, *row]
|
|
190
|
+
for found, groups in results
|
|
191
|
+
for row in _group_rows(groups)
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
{"device": dict(found.raw), "groups": [dict(group.raw) for group in groups]}
|
|
195
|
+
for found, groups in results
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
if len(devices) > 1:
|
|
200
|
+
render.warn(
|
|
201
|
+
f"{len(devices)} Entra devices are named {devices[0].display_name!r} "
|
|
202
|
+
"(stale registrations keep the name)"
|
|
203
|
+
)
|
|
204
|
+
for found, groups in results:
|
|
205
|
+
render.echo(
|
|
206
|
+
render.title(
|
|
207
|
+
f"{found.display_name} object {found.id} {found.operating_system} "
|
|
208
|
+
f"{found.os_version} last sign-in {render.when(found.last_sign_in)}"
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
if groups:
|
|
212
|
+
render.echo(
|
|
213
|
+
render.table(["GROUP", "OBJECT ID", "MEMBERSHIP", "SECURITY"], _group_rows(groups))
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
render.echo("(no group memberships)")
|
|
217
|
+
render.echo()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@entra_app.command("group-devices")
|
|
221
|
+
def group_devices(
|
|
222
|
+
ctx: typer.Context,
|
|
223
|
+
group: Annotated[str, typer.Argument(help="Group display name or object id.")],
|
|
224
|
+
profile: ProfileOption = None,
|
|
225
|
+
direct: DirectOption = False,
|
|
226
|
+
output: OutputOption = Output.TABLE,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""List the devices in an Entra group."""
|
|
229
|
+
runtime = get_runtime(ctx).microsoft
|
|
230
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
231
|
+
found = entra.get_group(group)
|
|
232
|
+
devices = entra.group_devices(found, transitive=not direct)
|
|
233
|
+
if output is Output.TABLE:
|
|
234
|
+
membership = "dynamic" if found.dynamic else "assigned"
|
|
235
|
+
render.echo(
|
|
236
|
+
render.title(
|
|
237
|
+
f"{found.display_name} object {found.id} {membership} {len(devices)} device(s)"
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
render.emit(
|
|
241
|
+
output,
|
|
242
|
+
["DEVICE", "OS", "VERSION", "ENABLED", "TRUST", "LAST SIGN-IN", "DEVICE ID"],
|
|
243
|
+
[
|
|
244
|
+
[
|
|
245
|
+
device.display_name,
|
|
246
|
+
device.operating_system,
|
|
247
|
+
device.os_version,
|
|
248
|
+
render.yes_no(device.enabled),
|
|
249
|
+
device.trust_type,
|
|
250
|
+
render.when(device.last_sign_in),
|
|
251
|
+
device.device_id,
|
|
252
|
+
]
|
|
253
|
+
for device in devices
|
|
254
|
+
],
|
|
255
|
+
{"group": dict(found.raw), "devices": [dict(device.raw) for device in devices]},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@entra_app.command("group-members")
|
|
260
|
+
def group_members(
|
|
261
|
+
ctx: typer.Context,
|
|
262
|
+
group: Annotated[str, typer.Argument(help="Group display name or object id.")],
|
|
263
|
+
kind: Annotated[
|
|
264
|
+
str | None,
|
|
265
|
+
typer.Option("--kind", help="Only this kind: user, device, group or servicePrincipal."),
|
|
266
|
+
] = None,
|
|
267
|
+
profile: ProfileOption = None,
|
|
268
|
+
direct: DirectOption = False,
|
|
269
|
+
output: OutputOption = Output.TABLE,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""List the members of an Entra group, of every kind or of one."""
|
|
272
|
+
kinds: dict[str, MemberKind] = {
|
|
273
|
+
"user": "user",
|
|
274
|
+
"device": "device",
|
|
275
|
+
"group": "group",
|
|
276
|
+
"serviceprincipal": "servicePrincipal",
|
|
277
|
+
}
|
|
278
|
+
if kind is not None and kind.casefold() not in kinds:
|
|
279
|
+
raise typer.BadParameter(f"--kind must be one of {', '.join(kinds.values())}")
|
|
280
|
+
runtime = get_runtime(ctx).microsoft
|
|
281
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
282
|
+
found = entra.get_group(group)
|
|
283
|
+
members = entra.group_members(
|
|
284
|
+
found, transitive=not direct, kind=kinds[kind.casefold()] if kind else None
|
|
285
|
+
)
|
|
286
|
+
if output is Output.TABLE:
|
|
287
|
+
render.echo(render.title(f"{found.display_name} {len(members)} member(s)"))
|
|
288
|
+
render.emit(
|
|
289
|
+
output,
|
|
290
|
+
["KIND", "NAME", "DETAIL", "OBJECT ID"],
|
|
291
|
+
[[member.kind, member.display_name, member.detail, member.id] for member in members],
|
|
292
|
+
{"group": dict(found.raw), "members": [dict(member.raw) for member in members]},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@entra_app.command("user-groups")
|
|
297
|
+
def user_groups(
|
|
298
|
+
ctx: typer.Context,
|
|
299
|
+
user: Annotated[str, typer.Argument(help="User principal name, object id or display name.")],
|
|
300
|
+
profile: ProfileOption = None,
|
|
301
|
+
direct: DirectOption = False,
|
|
302
|
+
output: OutputOption = Output.TABLE,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""List the Entra groups a user belongs to."""
|
|
305
|
+
runtime = get_runtime(ctx).microsoft
|
|
306
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
307
|
+
found = entra.get_user(user)
|
|
308
|
+
groups = entra.user_groups(found, transitive=not direct)
|
|
309
|
+
if output is Output.TABLE:
|
|
310
|
+
render.echo(
|
|
311
|
+
render.title(f"{found.user_principal_name} object {found.id} {len(groups)} group(s)")
|
|
312
|
+
)
|
|
313
|
+
render.emit(
|
|
314
|
+
output,
|
|
315
|
+
["GROUP", "OBJECT ID", "MEMBERSHIP", "SECURITY"],
|
|
316
|
+
_group_rows(groups),
|
|
317
|
+
{"user": dict(found.raw), "groups": [dict(group.raw) for group in groups]},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@entra_app.command("user-roles")
|
|
322
|
+
def user_roles(
|
|
323
|
+
ctx: typer.Context,
|
|
324
|
+
user: Annotated[str, typer.Argument(help="User principal name, object id or display name.")],
|
|
325
|
+
profile: ProfileOption = None,
|
|
326
|
+
output: OutputOption = Output.TABLE,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""List the directory roles a user holds now, and those PIM makes them eligible for."""
|
|
329
|
+
runtime = get_runtime(ctx).microsoft
|
|
330
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
331
|
+
found = entra.get_user(user)
|
|
332
|
+
report = entra.user_roles(found)
|
|
333
|
+
roles: list[RoleAssignment] = [*report.active, *(report.eligible or ())]
|
|
334
|
+
if output is Output.TABLE:
|
|
335
|
+
render.echo(render.title(f"{found.user_principal_name} object {found.id}"))
|
|
336
|
+
render.emit(
|
|
337
|
+
output,
|
|
338
|
+
["ROLE", "STATE", "SCOPE", "ENDS"],
|
|
339
|
+
[
|
|
340
|
+
[
|
|
341
|
+
role.role_name,
|
|
342
|
+
("active", "green") if role.state == "active" else ("eligible", "yellow"),
|
|
343
|
+
role.scope,
|
|
344
|
+
render.when(role.ends) if role.ends else "permanent",
|
|
345
|
+
]
|
|
346
|
+
for role in roles
|
|
347
|
+
],
|
|
348
|
+
{
|
|
349
|
+
"user": dict(found.raw),
|
|
350
|
+
"active": [dict(role.raw) for role in report.active],
|
|
351
|
+
"eligible": None
|
|
352
|
+
if report.eligible is None
|
|
353
|
+
else [dict(role.raw) for role in report.eligible],
|
|
354
|
+
"eligible_error": report.eligible_error,
|
|
355
|
+
},
|
|
356
|
+
)
|
|
357
|
+
if report.eligible is None:
|
|
358
|
+
render.warn(f"PIM eligibility could not be read: {report.eligible_error}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@entra_app.command("sign-ins")
|
|
362
|
+
def sign_ins(
|
|
363
|
+
ctx: typer.Context,
|
|
364
|
+
user: Annotated[
|
|
365
|
+
str | None, typer.Option("--user", "-u", help="Only this user (UPN or object id).")
|
|
366
|
+
] = None,
|
|
367
|
+
since: Annotated[
|
|
368
|
+
str, typer.Option("--since", help="How far back to look, e.g. 1h, 24h, 7d.")
|
|
369
|
+
] = "24h",
|
|
370
|
+
failures: Annotated[bool, typer.Option("--failures", help="Only failed sign-ins.")] = False,
|
|
371
|
+
limit: Annotated[int, typer.Option("--limit", min=1, help="Most sign-ins to show.")] = 50,
|
|
372
|
+
profile: ProfileOption = None,
|
|
373
|
+
output: OutputOption = Output.TABLE,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""List recent sign-ins, newest first. Needs Entra ID P1."""
|
|
376
|
+
window = duration(since) or timedelta(hours=24)
|
|
377
|
+
runtime = get_runtime(ctx).microsoft
|
|
378
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
379
|
+
events = entra.sign_ins(
|
|
380
|
+
user=user, since=datetime.now(UTC) - window, failures_only=failures, limit=limit
|
|
381
|
+
)
|
|
382
|
+
render.emit(
|
|
383
|
+
output,
|
|
384
|
+
["WHEN", "USER", "APP", "RESULT", "IP", "LOCATION", "DEVICE", "CA"],
|
|
385
|
+
[
|
|
386
|
+
[
|
|
387
|
+
render.when(event.created),
|
|
388
|
+
event.user,
|
|
389
|
+
event.app,
|
|
390
|
+
("success", "green")
|
|
391
|
+
if event.succeeded
|
|
392
|
+
else (f"{event.error_code} {event.failure_reason}", "red"),
|
|
393
|
+
event.ip_address,
|
|
394
|
+
event.location,
|
|
395
|
+
event.device_name,
|
|
396
|
+
event.conditional_access,
|
|
397
|
+
]
|
|
398
|
+
for event in events
|
|
399
|
+
],
|
|
400
|
+
[dict(event.raw) for event in events],
|
|
401
|
+
)
|
|
402
|
+
if output is Output.TABLE:
|
|
403
|
+
render.note(f"{len(events)} sign-in(s) in the last {format_duration(window)}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@entra_app.command("app-credentials")
|
|
407
|
+
def app_credentials(
|
|
408
|
+
ctx: typer.Context,
|
|
409
|
+
within: Annotated[
|
|
410
|
+
str, typer.Option("--expiring", help="Show credentials ending within this, e.g. 30d.")
|
|
411
|
+
] = "30d",
|
|
412
|
+
show_all: Annotated[
|
|
413
|
+
bool, typer.Option("--all", help="Show every credential, not only expiring ones.")
|
|
414
|
+
] = False,
|
|
415
|
+
service_principals: Annotated[
|
|
416
|
+
bool,
|
|
417
|
+
typer.Option(
|
|
418
|
+
"--service-principals",
|
|
419
|
+
help="Include enterprise apps (service principals), e.g. SAML signing certificates.",
|
|
420
|
+
),
|
|
421
|
+
] = False,
|
|
422
|
+
include_expired: Annotated[
|
|
423
|
+
bool, typer.Option("--include-expired/--hide-expired", help="Show expired credentials.")
|
|
424
|
+
] = True,
|
|
425
|
+
profile: ProfileOption = None,
|
|
426
|
+
output: OutputOption = Output.TABLE,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""List app registration secrets and certificates close to expiry.
|
|
429
|
+
|
|
430
|
+
Exits 3 when any credential shown is expiring or expired, so a scheduled job can alert.
|
|
431
|
+
"""
|
|
432
|
+
window = duration(within) or timedelta(days=30)
|
|
433
|
+
now = datetime.now(UTC)
|
|
434
|
+
runtime = get_runtime(ctx).microsoft
|
|
435
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
436
|
+
credentials = entra.app_credentials(include_service_principals=service_principals)
|
|
437
|
+
shown = (
|
|
438
|
+
sorted(credentials, key=lambda item: item.ends or now)
|
|
439
|
+
if show_all
|
|
440
|
+
else expiring(credentials, window, now=now, include_expired=include_expired)
|
|
441
|
+
)
|
|
442
|
+
render.emit(
|
|
443
|
+
output,
|
|
444
|
+
["APP", "KIND", "CREDENTIAL", "ENDS", "DAYS LEFT", "OWNER", "APP ID"],
|
|
445
|
+
[_credential_row(item, now, window) for item in shown],
|
|
446
|
+
[
|
|
447
|
+
{
|
|
448
|
+
"owner_kind": item.owner_kind,
|
|
449
|
+
"owner_name": item.owner_name,
|
|
450
|
+
"owner_id": item.owner_id,
|
|
451
|
+
"app_id": item.app_id,
|
|
452
|
+
"kind": item.kind,
|
|
453
|
+
"credential": dict(item.raw),
|
|
454
|
+
"days_left": item.days_left(now),
|
|
455
|
+
}
|
|
456
|
+
for item in shown
|
|
457
|
+
],
|
|
458
|
+
)
|
|
459
|
+
attention = [item for item in shown if item.ends is not None and item.ends - now <= window]
|
|
460
|
+
if output is Output.TABLE:
|
|
461
|
+
render.note(
|
|
462
|
+
f"{len(attention)} credential(s) end within {format_duration(window)} "
|
|
463
|
+
f"(of {len(credentials)} checked)"
|
|
464
|
+
)
|
|
465
|
+
if attention:
|
|
466
|
+
raise typer.Exit(ATTENTION)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _credential_row(item: AppCredential, now: datetime, window: timedelta) -> list[render.Cell]:
|
|
470
|
+
days = item.days_left(now)
|
|
471
|
+
if days is None:
|
|
472
|
+
left: render.Cell = "-"
|
|
473
|
+
elif days < 0:
|
|
474
|
+
left = (f"expired {-days}d ago", "red")
|
|
475
|
+
elif item.ends is not None and item.ends - now <= window:
|
|
476
|
+
left = (str(days), "yellow")
|
|
477
|
+
else:
|
|
478
|
+
left = str(days)
|
|
479
|
+
return [
|
|
480
|
+
item.owner_name,
|
|
481
|
+
item.kind,
|
|
482
|
+
item.name,
|
|
483
|
+
render.when(item.ends),
|
|
484
|
+
left,
|
|
485
|
+
item.owner_kind,
|
|
486
|
+
item.app_id,
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@entra_app.command("ca-policies")
|
|
491
|
+
def ca_policies(
|
|
492
|
+
ctx: typer.Context,
|
|
493
|
+
state: Annotated[
|
|
494
|
+
str | None,
|
|
495
|
+
typer.Option("--state", help="Only this state: enabled, disabled or report-only."),
|
|
496
|
+
] = None,
|
|
497
|
+
profile: ProfileOption = None,
|
|
498
|
+
output: OutputOption = Output.TABLE,
|
|
499
|
+
) -> None:
|
|
500
|
+
"""List Conditional Access policies: who and what each targets, and what it requires.
|
|
501
|
+
|
|
502
|
+
Needs Policy.Read.All, which the Azure CLI's token does not carry: use a profile
|
|
503
|
+
with its own app registration.
|
|
504
|
+
"""
|
|
505
|
+
states = {
|
|
506
|
+
"enabled": "enabled",
|
|
507
|
+
"disabled": "disabled",
|
|
508
|
+
"report-only": "enabledForReportingButNotEnforced",
|
|
509
|
+
}
|
|
510
|
+
if state is not None and state not in states:
|
|
511
|
+
raise typer.BadParameter(f"--state must be one of {', '.join(states)}")
|
|
512
|
+
runtime = get_runtime(ctx).microsoft
|
|
513
|
+
entra = runtime.entra(runtime.profile(profile))
|
|
514
|
+
policies = [
|
|
515
|
+
policy for policy in entra.ca_policies() if state is None or policy.state == states[state]
|
|
516
|
+
]
|
|
517
|
+
state_names = {value: key for key, value in states.items()}
|
|
518
|
+
render.emit(
|
|
519
|
+
output,
|
|
520
|
+
["POLICY", "STATE", "USERS", "APPS", "GRANT", "SESSION", "MODIFIED"],
|
|
521
|
+
[
|
|
522
|
+
[
|
|
523
|
+
policy.display_name,
|
|
524
|
+
state_names.get(policy.state, policy.state),
|
|
525
|
+
_targets(
|
|
526
|
+
policy.include_users + policy.include_groups + policy.include_roles,
|
|
527
|
+
policy.exclude_users + policy.exclude_groups,
|
|
528
|
+
),
|
|
529
|
+
_targets(policy.include_applications, policy.exclude_applications),
|
|
530
|
+
f" {policy.grant_operator} ".join(policy.grant_controls) or "-",
|
|
531
|
+
", ".join(policy.session_controls),
|
|
532
|
+
render.when(policy.modified),
|
|
533
|
+
]
|
|
534
|
+
for policy in policies
|
|
535
|
+
],
|
|
536
|
+
[dict(policy.raw) for policy in policies],
|
|
537
|
+
)
|
|
538
|
+
if not policies and state is None:
|
|
539
|
+
# Graph answers an unauthorised listing with an empty list rather than an error.
|
|
540
|
+
render.warn(
|
|
541
|
+
"no Conditional Access policies returned; if you expected some, the token may lack "
|
|
542
|
+
"Policy.Read.All, which the Azure CLI's token never has"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _targets(include: tuple[str, ...], exclude: tuple[str, ...]) -> str:
|
|
547
|
+
"""``All`` or ``3 included`` plus ``, 1 excluded``: ids alone mean little in a table."""
|
|
548
|
+
if not include:
|
|
549
|
+
text = "none"
|
|
550
|
+
elif len(include) == 1 and not is_guid(include[0]):
|
|
551
|
+
text = include[0]
|
|
552
|
+
else:
|
|
553
|
+
text = f"{len(include)} included"
|
|
554
|
+
return f"{text}, {len(exclude)} excluded" if exclude else text
|