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,286 @@
1
+ """Check a list of devices against expectations, once (fast) or until done (watch).
2
+
3
+ Speed comes from three things. Lookups for different devices run in parallel. Each
4
+ expected group's members are fetched once per pass rather than once per device. And a
5
+ watch rechecks only the devices not yet complete, unless asked to recheck them all.
6
+
7
+ A rejected token or a missing permission (HTTP 401 or 403) ends the check, since every
8
+ device would fail the same way. Anything else that fails for one device is recorded
9
+ as an error for that device, and a watch simply tries again next pass.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from datetime import datetime
18
+ from typing import TypeVar
19
+
20
+ from libre_devops_helpers.core.auth import utc_now
21
+ from libre_devops_helpers.core.errors import ApiError, LdoError, ReauthRequired
22
+ from libre_devops_helpers.core.poll import PollLimits, PollOutcome, poll
23
+ from libre_devops_helpers.microsoft.devices.models import (
24
+ CheckRun,
25
+ DeviceReport,
26
+ Expectations,
27
+ Outcome,
28
+ )
29
+ from libre_devops_helpers.microsoft.entra.client import EntraClient
30
+ from libre_devops_helpers.microsoft.entra.models import EntraDevice
31
+ from libre_devops_helpers.microsoft.intune.client import IntuneClient
32
+ from libre_devops_helpers.microsoft.intune.models import ManagedDevice
33
+ from libre_devops_helpers.microsoft.xdr.client import XdrClient
34
+ from libre_devops_helpers.microsoft.xdr.models import MachineLookup
35
+
36
+ _FATAL = frozenset({401, 403})
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ class DeviceChecker:
42
+ """Checks devices against ``Expectations`` using whichever clients they need."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ entra: EntraClient | None = None,
48
+ xdr: XdrClient | None = None,
49
+ intune: IntuneClient | None = None,
50
+ workers: int = 8,
51
+ clock: Callable[[], datetime] = utc_now,
52
+ ) -> None:
53
+ if workers < 1:
54
+ raise LdoError("workers must be at least 1")
55
+ self._entra = entra
56
+ self._xdr = xdr
57
+ self._intune = intune
58
+ self._workers = workers
59
+ self._clock = clock
60
+
61
+ def check(self, names: Sequence[str], expectations: Expectations) -> CheckRun:
62
+ """One pass over ``names``, in order."""
63
+ self._require_clients(expectations)
64
+ self._prepare(expectations)
65
+ members = self._group_members(expectations.groups)
66
+ if not names:
67
+ return CheckRun((), self._clock(), 0)
68
+ try:
69
+ reports = self._check_all(names, expectations, members)
70
+ except ReauthRequired:
71
+ # A sign-in lapsed mid-pass, in a worker, which must not stop to ask anyone.
72
+ # Ask here on the calling thread (this raises again if nobody can sign in),
73
+ # then run the pass again.
74
+ self._prepare(expectations)
75
+ reports = self._check_all(names, expectations, members)
76
+ return CheckRun(tuple(reports), self._clock(), len(names))
77
+
78
+ def _prepare(self, expectations: Expectations) -> None:
79
+ """Get each service's token on this thread before the workers need it.
80
+
81
+ A token is normally cached already, so this is cheap; when a sign-in has lapsed,
82
+ it is where a credential that can ask someone to sign in again gets to ask.
83
+ """
84
+ for needed, client in (
85
+ (expectations.needs_entra or expectations.groups, self._entra),
86
+ (expectations.needs_defender, self._xdr),
87
+ (expectations.needs_intune, self._intune),
88
+ ):
89
+ if needed and client is not None:
90
+ client.api.ensure_token()
91
+
92
+ def _check_all(
93
+ self,
94
+ names: Sequence[str],
95
+ expectations: Expectations,
96
+ members: Mapping[str, frozenset[str]],
97
+ ) -> list[DeviceReport]:
98
+ with ThreadPoolExecutor(max_workers=min(self._workers, len(names))) as pool:
99
+ return list(pool.map(lambda name: self._check_one(name, expectations, members), names))
100
+
101
+ def _require_clients(self, expectations: Expectations) -> None:
102
+ missing = [
103
+ service
104
+ for service, needed, client in (
105
+ ("Entra", expectations.needs_entra, self._entra),
106
+ ("Defender", expectations.needs_defender, self._xdr),
107
+ ("Intune", expectations.needs_intune, self._intune),
108
+ )
109
+ if needed and client is None
110
+ ]
111
+ if missing:
112
+ raise LdoError(f"these checks need a client for {', '.join(missing)}")
113
+
114
+ def _group_members(self, groups: Sequence[str]) -> dict[str, frozenset[str]]:
115
+ if not groups or self._entra is None:
116
+ return {}
117
+ entra = self._entra
118
+ members: dict[str, frozenset[str]] = {}
119
+ for ref in groups:
120
+ group = entra.get_group(ref)
121
+ members[ref] = frozenset(device.id for device in entra.group_devices(group))
122
+ return members
123
+
124
+ def _check_one(
125
+ self, name: str, expectations: Expectations, members: Mapping[str, frozenset[str]]
126
+ ) -> DeviceReport:
127
+ errors: dict[str, str] = {}
128
+ entra_devices: tuple[EntraDevice, ...] = ()
129
+ lookup: MachineLookup | None = None
130
+ managed: tuple[ManagedDevice, ...] = ()
131
+ if expectations.needs_entra and self._entra is not None:
132
+ entra = self._entra
133
+ entra_devices = _guarded(errors, "entra", lambda: tuple(entra.find_devices(name)), ())
134
+ if expectations.needs_defender and self._xdr is not None:
135
+ xdr = self._xdr
136
+ lookup = _guarded(errors, "defender", lambda: xdr.find_machine(name), None)
137
+ if expectations.needs_intune and self._intune is not None:
138
+ intune = self._intune
139
+ managed = _guarded(errors, "intune", lambda: tuple(intune.find_devices(name)), ())
140
+
141
+ outcomes: list[Outcome] = []
142
+ if expectations.in_entra:
143
+ outcomes.append(_entra_outcome(entra_devices, errors.get("entra")))
144
+ machine = lookup.machine if lookup is not None else None
145
+ defender_error = errors.get("defender")
146
+ if expectations.onboarded:
147
+ if defender_error:
148
+ outcomes.append(Outcome("defender", "error", defender_error))
149
+ elif machine is None:
150
+ outcomes.append(Outcome("defender", "unmet", "no Defender record"))
151
+ elif machine.onboarding_status == "Onboarded":
152
+ outcomes.append(Outcome("defender", "met", f"onboarded, {machine.health_status}"))
153
+ else:
154
+ outcomes.append(
155
+ Outcome("defender", "unmet", machine.onboarding_status or "not onboarded")
156
+ )
157
+ if expectations.active:
158
+ if defender_error:
159
+ outcomes.append(Outcome("active", "error", defender_error))
160
+ elif machine is None:
161
+ outcomes.append(Outcome("active", "unmet", "no Defender record"))
162
+ elif machine.health_status == "Active":
163
+ outcomes.append(Outcome("active", "met", "Active"))
164
+ else:
165
+ outcomes.append(Outcome("active", "unmet", machine.health_status or "unknown"))
166
+ for tag in expectations.tags:
167
+ check = f"tag {tag}"
168
+ if defender_error:
169
+ outcomes.append(Outcome(check, "error", defender_error))
170
+ elif machine is None:
171
+ outcomes.append(Outcome(check, "unmet", "no Defender record"))
172
+ elif tag.casefold() in {item.casefold() for item in machine.machine_tags}:
173
+ outcomes.append(Outcome(check, "met", "tagged"))
174
+ else:
175
+ outcomes.append(Outcome(check, "unmet", "tag missing"))
176
+ for device_group in expectations.device_groups:
177
+ check = f"device group {device_group}"
178
+ if defender_error:
179
+ outcomes.append(Outcome(check, "error", defender_error))
180
+ elif machine is None:
181
+ outcomes.append(Outcome(check, "unmet", "no Defender record"))
182
+ elif machine.device_group.casefold() == device_group.casefold():
183
+ outcomes.append(Outcome(check, "met", "in the device group"))
184
+ else:
185
+ outcomes.append(Outcome(check, "unmet", f"in {machine.device_group or 'none'}"))
186
+ for group in expectations.groups:
187
+ check = f"group {group}"
188
+ if errors.get("entra"):
189
+ outcomes.append(Outcome(check, "error", errors["entra"]))
190
+ elif not entra_devices:
191
+ outcomes.append(Outcome(check, "unmet", "not in Entra"))
192
+ elif any(device.id in members.get(group, frozenset()) for device in entra_devices):
193
+ outcomes.append(Outcome(check, "met", "member"))
194
+ else:
195
+ outcomes.append(Outcome(check, "unmet", "not a member"))
196
+ newest = managed[0] if managed else None
197
+ if expectations.in_intune:
198
+ if errors.get("intune"):
199
+ outcomes.append(Outcome("intune", "error", errors["intune"]))
200
+ elif newest is None:
201
+ outcomes.append(Outcome("intune", "unmet", "not enrolled in Intune"))
202
+ else:
203
+ outcomes.append(Outcome("intune", "met", newest.management_agent or "enrolled"))
204
+ if expectations.compliant:
205
+ if errors.get("intune"):
206
+ outcomes.append(Outcome("compliant", "error", errors["intune"]))
207
+ elif newest is None:
208
+ outcomes.append(Outcome("compliant", "unmet", "not enrolled in Intune"))
209
+ elif newest.compliant:
210
+ outcomes.append(Outcome("compliant", "met", "compliant"))
211
+ else:
212
+ outcomes.append(Outcome("compliant", "unmet", newest.compliance_state or "unknown"))
213
+ return DeviceReport(name, tuple(outcomes), entra_devices, lookup, managed)
214
+
215
+
216
+ def watch(
217
+ checker: DeviceChecker,
218
+ names: Sequence[str],
219
+ expectations: Expectations,
220
+ limits: PollLimits,
221
+ *,
222
+ recheck: bool = False,
223
+ clock: Callable[[], float] = time.monotonic,
224
+ sleep: Callable[[float], None] = time.sleep,
225
+ on_pass: Callable[[int, CheckRun], None] | None = None,
226
+ on_wait: Callable[[float], None] | None = None,
227
+ ) -> PollOutcome[CheckRun]:
228
+ """Check ``names`` every ``limits.interval`` until all are complete or a limit hits.
229
+
230
+ A device that met everything is not checked again unless ``recheck`` is set, so
231
+ later passes only cost calls for the devices still outstanding. A pass that fails
232
+ outright (other than on a rejected token) is reported and the next pass tries again.
233
+ """
234
+ latest: dict[str, DeviceReport] = {}
235
+ order = list(names)
236
+
237
+ def current(error: str | None = None) -> CheckRun:
238
+ reports = tuple(latest[name] for name in order if name in latest)
239
+ return CheckRun(reports, utc_now(), len(order), error)
240
+
241
+ def run_pass(_number: int) -> CheckRun:
242
+ pending = (
243
+ order
244
+ if recheck
245
+ else [name for name in order if name not in latest or not latest[name].complete]
246
+ )
247
+ try:
248
+ run = checker.check(pending, expectations)
249
+ except ApiError as exc:
250
+ if exc.status in _FATAL:
251
+ raise
252
+ return current(str(exc))
253
+ for report in run.reports:
254
+ latest[report.name] = report
255
+ return current()
256
+
257
+ return poll(
258
+ run_pass,
259
+ lambda run: run.complete,
260
+ limits,
261
+ clock=clock,
262
+ sleep=sleep,
263
+ on_pass=on_pass,
264
+ on_wait=on_wait,
265
+ )
266
+
267
+
268
+ def _guarded(errors: dict[str, str], key: str, call: Callable[[], T], default: T) -> T:
269
+ try:
270
+ return call()
271
+ except ApiError as exc:
272
+ if exc.status in _FATAL:
273
+ raise
274
+ errors[key] = str(exc)
275
+ return default
276
+
277
+
278
+ def _entra_outcome(devices: tuple[EntraDevice, ...], error: str | None) -> Outcome:
279
+ if error:
280
+ return Outcome("entra", "error", error)
281
+ if not devices:
282
+ return Outcome("entra", "unmet", "not in Entra")
283
+ if all(device.enabled is False for device in devices):
284
+ return Outcome("entra", "unmet", "disabled in Entra")
285
+ detail = "present" if len(devices) == 1 else f"{len(devices)} objects share the name"
286
+ return Outcome("entra", "met", detail)
@@ -0,0 +1,146 @@
1
+ """One device across Entra, Defender and Intune, and what looks wrong about it.
2
+
3
+ Entra is required: it is where a device's identity lives. Defender and Intune are
4
+ optional, and a failure reading either one (a suspended service, a token without the
5
+ scope) becomes a finding rather than an error, so the rest of the picture still shows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta
11
+
12
+ from libre_devops_helpers.core.auth import utc_now
13
+ from libre_devops_helpers.core.errors import ApiError
14
+ from libre_devops_helpers.core.util import format_duration
15
+ from libre_devops_helpers.microsoft.devices.models import DeviceView, Finding
16
+ from libre_devops_helpers.microsoft.entra.client import EntraClient
17
+ from libre_devops_helpers.microsoft.entra.models import EntraDevice, EntraGroup
18
+ from libre_devops_helpers.microsoft.intune.client import IntuneClient
19
+ from libre_devops_helpers.microsoft.intune.models import ManagedDevice
20
+ from libre_devops_helpers.microsoft.xdr.client import XdrClient
21
+ from libre_devops_helpers.microsoft.xdr.models import MachineLookup
22
+
23
+
24
+ def inspect_device(
25
+ name: str,
26
+ *,
27
+ entra: EntraClient,
28
+ xdr: XdrClient | None = None,
29
+ intune: IntuneClient | None = None,
30
+ stale_after: timedelta = timedelta(days=7),
31
+ now: datetime | None = None,
32
+ ) -> DeviceView:
33
+ """Everything the services know about ``name``, with findings worth acting on."""
34
+ now = now or utc_now()
35
+ findings: list[Finding] = []
36
+
37
+ devices = tuple(entra.find_devices(name))
38
+ groups: dict[str, tuple[EntraGroup, ...]] = {
39
+ device.id: tuple(entra.device_groups(device)) for device in devices
40
+ }
41
+ findings.extend(_entra_findings(devices))
42
+
43
+ lookup: MachineLookup | None = None
44
+ if xdr is not None:
45
+ try:
46
+ lookup = xdr.find_machine(name)
47
+ except ApiError as exc:
48
+ findings.append(Finding("warn", f"Defender could not be read: {exc}"))
49
+ else:
50
+ findings.extend(_defender_findings(lookup, devices, stale_after, now))
51
+
52
+ managed: tuple[ManagedDevice, ...] | None = None
53
+ if intune is not None:
54
+ try:
55
+ managed = tuple(intune.find_devices(name))
56
+ except ApiError as exc:
57
+ findings.append(Finding("warn", f"Intune could not be read: {exc}"))
58
+ else:
59
+ findings.extend(_intune_findings(managed, devices))
60
+
61
+ if not any(finding.level == "warn" for finding in findings):
62
+ findings.append(Finding("ok", "nothing looks wrong"))
63
+ return DeviceView(name, devices, groups, lookup, managed, tuple(findings))
64
+
65
+
66
+ def _entra_findings(devices: tuple[EntraDevice, ...]) -> list[Finding]:
67
+ if not devices:
68
+ return [Finding("warn", "not in Entra")]
69
+ found: list[Finding] = []
70
+ if len(devices) > 1:
71
+ found.append(
72
+ Finding("warn", f"{len(devices)} Entra objects share this name (stale registrations)")
73
+ )
74
+ found.extend(
75
+ Finding("warn", f"Entra object {device.id} is disabled")
76
+ for device in devices
77
+ if device.enabled is False
78
+ )
79
+ return found
80
+
81
+
82
+ def _defender_findings(
83
+ lookup: MachineLookup,
84
+ devices: tuple[EntraDevice, ...],
85
+ stale_after: timedelta,
86
+ now: datetime,
87
+ ) -> list[Finding]:
88
+ machine = lookup.machine
89
+ if machine is None:
90
+ return [Finding("warn", "no Defender record")]
91
+ found: list[Finding] = []
92
+ if len(lookup.records) > 1:
93
+ found.append(Finding("warn", f"{len(lookup.records)} Defender records; the newest is used"))
94
+ if lookup.matched_name != lookup.query.strip().rstrip("."):
95
+ found.append(Finding("info", f"Defender knows it by its short name {lookup.matched_name}"))
96
+ if machine.onboarding_status != "Onboarded":
97
+ found.append(
98
+ Finding("warn", f"Defender onboarding status is {machine.onboarding_status or '-'}")
99
+ )
100
+ if machine.health_status != "Active":
101
+ found.append(Finding("warn", f"Defender health is {machine.health_status or '-'}"))
102
+ if machine.last_seen is not None and now - machine.last_seen > stale_after:
103
+ found.append(
104
+ Finding("warn", f"Defender last saw it {format_duration(now - machine.last_seen)} ago")
105
+ )
106
+ if not machine.aad_device_id:
107
+ found.append(
108
+ Finding(
109
+ "info", "Defender has no Entra device id for it (not Entra joined or registered)"
110
+ )
111
+ )
112
+ elif devices and machine.aad_device_id.lower() not in {d.device_id.lower() for d in devices}:
113
+ found.append(
114
+ Finding(
115
+ "warn",
116
+ f"Defender links it to Entra device {machine.aad_device_id}, "
117
+ "which is not one of the Entra objects with this name",
118
+ )
119
+ )
120
+ return found
121
+
122
+
123
+ def _intune_findings(
124
+ managed: tuple[ManagedDevice, ...], devices: tuple[EntraDevice, ...]
125
+ ) -> list[Finding]:
126
+ if not managed:
127
+ return [Finding("info", "not enrolled in Intune")]
128
+ newest = managed[0]
129
+ found: list[Finding] = []
130
+ if len(managed) > 1:
131
+ found.append(Finding("warn", f"{len(managed)} Intune records; the newest sync is used"))
132
+ if not newest.compliant:
133
+ found.append(Finding("warn", f"Intune compliance is {newest.compliance_state or '-'}"))
134
+ if (
135
+ newest.azure_ad_device_id
136
+ and devices
137
+ and newest.azure_ad_device_id.lower() not in {d.device_id.lower() for d in devices}
138
+ ):
139
+ found.append(
140
+ Finding(
141
+ "warn",
142
+ f"Intune links it to Entra device {newest.azure_ad_device_id}, "
143
+ "which is not one of the Entra objects with this name",
144
+ )
145
+ )
146
+ return found
@@ -0,0 +1,148 @@
1
+ """What a device check expects, and what it found."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Literal
9
+
10
+ from libre_devops_helpers.core.errors import InputError
11
+ from libre_devops_helpers.microsoft.entra.models import EntraDevice, EntraGroup
12
+ from libre_devops_helpers.microsoft.intune.models import ManagedDevice
13
+ from libre_devops_helpers.microsoft.xdr.models import MachineLookup
14
+
15
+ Status = Literal["met", "unmet", "error"]
16
+ Level = Literal["ok", "info", "warn"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Expectations:
21
+ """The state every device should be in. Each enabled item is one check per device."""
22
+
23
+ in_entra: bool = True
24
+ onboarded: bool = True
25
+ active: bool = False
26
+ tags: tuple[str, ...] = ()
27
+ device_groups: tuple[str, ...] = ()
28
+ groups: tuple[str, ...] = ()
29
+ in_intune: bool = False
30
+ compliant: bool = False
31
+
32
+ def __post_init__(self) -> None:
33
+ if not self.checks:
34
+ raise InputError(
35
+ "nothing to check",
36
+ hint="enable at least one of Entra, Defender, tags, device groups, groups",
37
+ )
38
+
39
+ @property
40
+ def needs_entra(self) -> bool:
41
+ return self.in_entra or bool(self.groups)
42
+
43
+ @property
44
+ def needs_defender(self) -> bool:
45
+ return self.onboarded or self.active or bool(self.tags) or bool(self.device_groups)
46
+
47
+ @property
48
+ def needs_intune(self) -> bool:
49
+ return self.in_intune or self.compliant
50
+
51
+ @property
52
+ def checks(self) -> tuple[str, ...]:
53
+ """The check names, in the order they are reported."""
54
+ names: list[str] = []
55
+ if self.in_entra:
56
+ names.append("entra")
57
+ if self.onboarded:
58
+ names.append("defender")
59
+ if self.active:
60
+ names.append("active")
61
+ names.extend(f"tag {tag}" for tag in self.tags)
62
+ names.extend(f"device group {group}" for group in self.device_groups)
63
+ names.extend(f"group {group}" for group in self.groups)
64
+ if self.in_intune:
65
+ names.append("intune")
66
+ if self.compliant:
67
+ names.append("compliant")
68
+ return tuple(names)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Outcome:
73
+ """One check on one device."""
74
+
75
+ check: str
76
+ status: Status
77
+ detail: str
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DeviceReport:
82
+ """Everything one check pass found for one device name."""
83
+
84
+ name: str
85
+ outcomes: tuple[Outcome, ...]
86
+ entra: tuple[EntraDevice, ...] = ()
87
+ defender: MachineLookup | None = None
88
+ intune: tuple[ManagedDevice, ...] = ()
89
+
90
+ @property
91
+ def complete(self) -> bool:
92
+ return all(outcome.status == "met" for outcome in self.outcomes)
93
+
94
+ def outcome(self, check: str) -> Outcome | None:
95
+ return next((item for item in self.outcomes if item.check == check), None)
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class CheckRun:
100
+ """One pass over every device. ``error`` is set when the pass itself failed."""
101
+
102
+ reports: tuple[DeviceReport, ...]
103
+ checked_at: datetime
104
+ expected: int
105
+ error: str | None = None
106
+
107
+ @property
108
+ def complete(self) -> bool:
109
+ return (
110
+ self.error is None
111
+ and len(self.reports) == self.expected
112
+ and all(report.complete for report in self.reports)
113
+ )
114
+
115
+ def counts(self, checks: tuple[str, ...]) -> dict[str, int]:
116
+ """How many devices meet each check."""
117
+ return {
118
+ check: sum(
119
+ 1
120
+ for report in self.reports
121
+ if (outcome := report.outcome(check)) is not None and outcome.status == "met"
122
+ )
123
+ for check in checks
124
+ }
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Finding:
129
+ """One observation about a device from ``devices show``."""
130
+
131
+ level: Level
132
+ message: str
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class DeviceView:
137
+ """A device across Entra, Defender and Intune, with what looks wrong about it.
138
+
139
+ ``defender`` and ``intune`` are None when that service was not asked, or could not
140
+ be read (a finding says which).
141
+ """
142
+
143
+ name: str
144
+ entra: tuple[EntraDevice, ...]
145
+ groups: Mapping[str, tuple[EntraGroup, ...]] = field(default_factory=dict)
146
+ defender: MachineLookup | None = None
147
+ intune: tuple[ManagedDevice, ...] | None = None
148
+ findings: tuple[Finding, ...] = ()
@@ -0,0 +1,41 @@
1
+ """Entra ID through Microsoft Graph: devices, users, groups, roles, sign-ins, apps and policies.
2
+
3
+ Depends only on ``core`` and the shared Microsoft layer. Public API::
4
+
5
+ from libre_devops_helpers.microsoft.auth import AzureCliCredential
6
+ from libre_devops_helpers.microsoft.entra import EntraClient
7
+
8
+ with EntraClient.create(AzureCliCredential(), tenant_id) as entra:
9
+ for device in entra.find_devices("web01.corp.example.com"):
10
+ groups = entra.device_groups(device)
11
+ """
12
+
13
+ from libre_devops_helpers.microsoft.entra.client import EntraClient, MemberKind, expiring
14
+ from libre_devops_helpers.microsoft.entra.models import (
15
+ AppCredential,
16
+ ConditionalAccessPolicy,
17
+ DirectoryObject,
18
+ EntraDevice,
19
+ EntraGroup,
20
+ EntraUser,
21
+ RoleAssignment,
22
+ RoleReport,
23
+ SignIn,
24
+ )
25
+ from libre_devops_helpers.microsoft.entra.permissions import REQUIREMENTS
26
+
27
+ __all__ = [
28
+ "REQUIREMENTS",
29
+ "AppCredential",
30
+ "ConditionalAccessPolicy",
31
+ "DirectoryObject",
32
+ "EntraClient",
33
+ "EntraDevice",
34
+ "EntraGroup",
35
+ "EntraUser",
36
+ "MemberKind",
37
+ "RoleAssignment",
38
+ "RoleReport",
39
+ "SignIn",
40
+ "expiring",
41
+ ]