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