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,422 @@
1
+ """Read-only Entra ID queries through Microsoft Graph v1.0.
2
+
3
+ Devices, users, groups and their memberships, directory roles, sign-ins, app
4
+ registration credentials and Conditional Access policies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ from collections.abc import Iterable, Iterator
11
+ from datetime import datetime, timedelta
12
+ from typing import Any, Literal, Self, TypeVar
13
+ from urllib.parse import quote
14
+
15
+ import requests
16
+
17
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
18
+ from libre_devops_helpers.core.errors import (
19
+ AmbiguousError,
20
+ ApiError,
21
+ LdoError,
22
+ NotFoundError,
23
+ )
24
+ from libre_devops_helpers.core.http import ApiClient
25
+ from libre_devops_helpers.core.util import (
26
+ candidate_names,
27
+ is_guid,
28
+ odata_datetime,
29
+ odata_string,
30
+ parse_datetime,
31
+ )
32
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
33
+ from libre_devops_helpers.microsoft.config import Profile
34
+ from libre_devops_helpers.microsoft.entra.models import (
35
+ AppCredential,
36
+ ConditionalAccessPolicy,
37
+ DirectoryObject,
38
+ EntraDevice,
39
+ EntraGroup,
40
+ EntraUser,
41
+ RoleAssignment,
42
+ RoleReport,
43
+ SignIn,
44
+ )
45
+
46
+ # An OData cast on a directory relationship (".../microsoft.graph.group") is an
47
+ # advanced query: Graph requires ConsistencyLevel: eventual together with $count=true.
48
+ _EVENTUAL = {"ConsistencyLevel": "eventual"}
49
+ _CREDENTIAL_SELECT = "id,appId,displayName,passwordCredentials,keyCredentials"
50
+ _CREDENTIAL_KINDS: tuple[tuple[str, Literal["secret", "certificate"]], ...] = (
51
+ ("passwordCredentials", "secret"),
52
+ ("keyCredentials", "certificate"),
53
+ )
54
+
55
+ T = TypeVar("T")
56
+
57
+ MemberKind = Literal["user", "device", "group", "servicePrincipal"]
58
+
59
+
60
+ class EntraClient:
61
+ """Entra ID lookups. Close it (or use ``with``) when done."""
62
+
63
+ def __init__(self, api: ApiClient) -> None:
64
+ self.api = api
65
+
66
+ @classmethod
67
+ def create(
68
+ cls,
69
+ tokens: TokenProvider,
70
+ tenant_id: str,
71
+ *,
72
+ graph_url: str = PUBLIC.graph_url,
73
+ verify: bool | str = True,
74
+ session: requests.Session | None = None,
75
+ ) -> EntraClient:
76
+ """A client for ``tenant_id`` that takes its Graph tokens from ``tokens``."""
77
+ api = ApiClient(
78
+ graph_url,
79
+ token_source(tokens, graph_url, tenant_id),
80
+ name="Microsoft Graph",
81
+ verify=verify,
82
+ session=session,
83
+ )
84
+ return cls(api)
85
+
86
+ @classmethod
87
+ def for_profile(
88
+ cls,
89
+ profile: Profile,
90
+ tokens: TokenProvider,
91
+ *,
92
+ verify: bool | str = True,
93
+ session: requests.Session | None = None,
94
+ ) -> EntraClient:
95
+ """A client for a configured profile's tenant, in the profile's cloud."""
96
+ return cls.create(
97
+ tokens,
98
+ profile.tenant_id,
99
+ graph_url=profile.cloud.graph_url,
100
+ verify=verify,
101
+ session=session,
102
+ )
103
+
104
+ def close(self) -> None:
105
+ self.api.close()
106
+
107
+ def __enter__(self) -> Self:
108
+ return self
109
+
110
+ def __exit__(self, *exc_info: object) -> None:
111
+ self.close()
112
+
113
+ # Devices ----------------------------------------------------------------------
114
+
115
+ def find_devices(self, name: str) -> list[EntraDevice]:
116
+ """Devices whose display name is ``name``, else its short hostname.
117
+
118
+ Several results are normal: stale registrations keep the same name.
119
+ """
120
+ for candidate in candidate_names(name):
121
+ devices = [
122
+ EntraDevice.from_json(item)
123
+ for item in self.api.get_all(
124
+ "/v1.0/devices",
125
+ params={
126
+ "$filter": f"displayName eq {odata_string(candidate)}",
127
+ "$select": EntraDevice.SELECT,
128
+ },
129
+ )
130
+ ]
131
+ if devices:
132
+ return devices
133
+ return []
134
+
135
+ def device_groups(
136
+ self, device: EntraDevice | str, *, transitive: bool = True
137
+ ) -> list[EntraGroup]:
138
+ """Groups a device belongs to, including nested ones unless ``transitive=False``."""
139
+ return self._groups_of("devices", _object_id(device), transitive)
140
+
141
+ def group_devices(
142
+ self, group: EntraGroup | str, *, transitive: bool = True
143
+ ) -> list[EntraDevice]:
144
+ """Device members of a group, including nested groups unless ``transitive=False``."""
145
+ relation = "transitiveMembers" if transitive else "members"
146
+ items = self.api.get_all(
147
+ f"/v1.0/groups/{_object_id(group)}/{relation}/microsoft.graph.device",
148
+ params={"$select": EntraDevice.SELECT, "$count": "true", "$top": "999"},
149
+ headers=_EVENTUAL,
150
+ )
151
+ devices = [EntraDevice.from_json(item) for item in items]
152
+ return sorted(devices, key=lambda device: device.display_name.casefold())
153
+
154
+ # Groups -----------------------------------------------------------------------
155
+
156
+ def get_group(self, ref: str) -> EntraGroup:
157
+ """One group, by object id or by exact display name.
158
+
159
+ Raises NotFoundError when nothing matches and AmbiguousError when a display name
160
+ is shared, since acting on the wrong group is worse than asking for its id.
161
+ """
162
+ ref = ref.strip()
163
+ if is_guid(ref):
164
+ data = self._get_one(
165
+ f"/v1.0/groups/{ref}", EntraGroup.SELECT, f"no Entra group has id {ref}"
166
+ )
167
+ return EntraGroup.from_json(data)
168
+ groups = [
169
+ EntraGroup.from_json(item)
170
+ for item in self._named("/v1.0/groups", ref, EntraGroup.SELECT)
171
+ ]
172
+ return _one(groups, ref, "Entra group", "Entra groups")
173
+
174
+ def group_members(
175
+ self,
176
+ group: EntraGroup | str,
177
+ *,
178
+ transitive: bool = True,
179
+ kind: MemberKind | None = None,
180
+ ) -> list[DirectoryObject]:
181
+ """Members of a group of any type, or of one ``kind`` only."""
182
+ relation = "transitiveMembers" if transitive else "members"
183
+ path = f"/v1.0/groups/{_object_id(group)}/{relation}"
184
+ if kind is None:
185
+ items: Iterable[dict[str, Any]] = self.api.get_all(path, params={"$top": "999"})
186
+ else:
187
+ items = self.api.get_all(
188
+ f"{path}/microsoft.graph.{kind}",
189
+ params={"$count": "true", "$top": "999"},
190
+ headers=_EVENTUAL,
191
+ )
192
+ members = [DirectoryObject.from_json(item, kind) for item in items]
193
+ return sorted(members, key=lambda member: (member.kind, member.display_name.casefold()))
194
+
195
+ # Users and principals ---------------------------------------------------------
196
+
197
+ def get_user(self, ref: str) -> EntraUser:
198
+ """One user, by object id, by user principal name, or by exact display name."""
199
+ ref = ref.strip()
200
+ if is_guid(ref) or "@" in ref:
201
+ # A guest UPN holds '#', so the segment must be percent-encoded.
202
+ data = self._get_one(
203
+ f"/v1.0/users/{quote(ref, safe='@')}",
204
+ EntraUser.SELECT,
205
+ f"no Entra user is {ref!r}",
206
+ )
207
+ return EntraUser.from_json(data)
208
+ users = [
209
+ EntraUser.from_json(item) for item in self._named("/v1.0/users", ref, EntraUser.SELECT)
210
+ ]
211
+ return _one(users, ref, "Entra user", "Entra users")
212
+
213
+ def user_groups(self, user: EntraUser | str, *, transitive: bool = True) -> list[EntraGroup]:
214
+ """Groups a user belongs to, including nested ones unless ``transitive=False``."""
215
+ return self._groups_of("users", _object_id(user), transitive)
216
+
217
+ def find_principal(self, ref: str) -> DirectoryObject:
218
+ """A user, group or service principal, by object id, UPN or exact display name.
219
+
220
+ Used to turn a name into the object id Azure role assignments are keyed on.
221
+ """
222
+ ref = ref.strip()
223
+ if is_guid(ref):
224
+ data = self._get_one(
225
+ f"/v1.0/directoryObjects/{ref}", None, f"no Entra object has id {ref}"
226
+ )
227
+ return DirectoryObject.from_json(data)
228
+ if "@" in ref:
229
+ return DirectoryObject.from_json(self.get_user(ref).raw, "user")
230
+ found = [
231
+ DirectoryObject.from_json(item, kind)
232
+ for path, kind in (
233
+ ("/v1.0/groups", "group"),
234
+ ("/v1.0/servicePrincipals", "servicePrincipal"),
235
+ ("/v1.0/users", "user"),
236
+ )
237
+ for item in self._named(path, ref, "id,displayName,appId,userPrincipalName")
238
+ ]
239
+ return _one(found, ref, "Entra user, group or service principal", "Entra objects")
240
+
241
+ # Roles ------------------------------------------------------------------------
242
+
243
+ def user_roles(self, user: EntraUser | str) -> RoleReport:
244
+ """Directory roles a user holds now, and those PIM makes them eligible for.
245
+
246
+ Active roles include ones held through a role-assignable group. Eligibility
247
+ needs PIM (Entra ID P2) and the right to read it; when it cannot be read, the
248
+ report says why instead of failing.
249
+ """
250
+ user_id = _object_id(user)
251
+ active = [
252
+ RoleAssignment.from_directory_role(item)
253
+ for item in self.api.get_all(
254
+ f"/v1.0/users/{user_id}/transitiveMemberOf/microsoft.graph.directoryRole",
255
+ params={"$count": "true"},
256
+ headers=_EVENTUAL,
257
+ )
258
+ ]
259
+ try:
260
+ eligible: list[RoleAssignment] | None = [
261
+ RoleAssignment.from_eligibility(item)
262
+ for item in self.api.get_all(
263
+ "/v1.0/roleManagement/directory/roleEligibilitySchedules",
264
+ params={
265
+ "$filter": f"principalId eq {odata_string(user_id)}",
266
+ "$expand": "roleDefinition",
267
+ },
268
+ )
269
+ ]
270
+ error = None
271
+ except ApiError as exc:
272
+ if exc.status not in {400, 403}:
273
+ raise
274
+ eligible, error = None, str(exc)
275
+ return RoleReport(
276
+ active=tuple(sorted(active, key=lambda role: role.role_name.casefold())),
277
+ eligible=tuple(sorted(eligible, key=lambda role: role.role_name.casefold()))
278
+ if eligible is not None
279
+ else None,
280
+ eligible_error=error,
281
+ )
282
+
283
+ # Sign-ins ---------------------------------------------------------------------
284
+
285
+ def sign_ins(
286
+ self,
287
+ *,
288
+ user: str | None = None,
289
+ since: datetime | None = None,
290
+ failures_only: bool = False,
291
+ limit: int = 50,
292
+ ) -> list[SignIn]:
293
+ """Recent sign-ins, newest first. Needs Entra ID P1 in the tenant."""
294
+ filters: list[str] = []
295
+ if user:
296
+ if is_guid(user):
297
+ filters.append(f"userId eq {odata_string(user.strip())}")
298
+ else:
299
+ filters.append(f"userPrincipalName eq {odata_string(user.strip())}")
300
+ if since is not None:
301
+ filters.append(f"createdDateTime ge {odata_datetime(since)}")
302
+ if failures_only:
303
+ filters.append("status/errorCode ne 0")
304
+ params = {"$top": str(max(1, min(limit, 999)))}
305
+ if filters:
306
+ params["$filter"] = " and ".join(filters)
307
+ items = self.api.get_all("/v1.0/auditLogs/signIns", params=params)
308
+ return [SignIn.from_json(item) for item in itertools.islice(items, limit)]
309
+
310
+ # App credentials --------------------------------------------------------------
311
+
312
+ def app_credentials(self, *, include_service_principals: bool = False) -> list[AppCredential]:
313
+ """Every secret and certificate on app registrations (and service principals)."""
314
+ sources: list[tuple[str, Literal["application", "service principal"]]] = [
315
+ ("/v1.0/applications", "application")
316
+ ]
317
+ if include_service_principals:
318
+ sources.append(("/v1.0/servicePrincipals", "service principal"))
319
+ found: list[AppCredential] = []
320
+ for path, owner_kind in sources:
321
+ for item in self.api.get_all(
322
+ path, params={"$select": _CREDENTIAL_SELECT, "$top": "999"}
323
+ ):
324
+ found.extend(_credentials(item, owner_kind))
325
+ return found
326
+
327
+ # Conditional Access -----------------------------------------------------------
328
+
329
+ def ca_policies(self) -> list[ConditionalAccessPolicy]:
330
+ """Every Conditional Access policy, sorted by name."""
331
+ policies = [
332
+ ConditionalAccessPolicy.from_json(item)
333
+ for item in self.api.get_all("/v1.0/identity/conditionalAccess/policies")
334
+ ]
335
+ return sorted(policies, key=lambda policy: policy.display_name.casefold())
336
+
337
+ # Helpers ----------------------------------------------------------------------
338
+
339
+ def _groups_of(self, collection: str, object_id: str, transitive: bool) -> list[EntraGroup]:
340
+ relation = "transitiveMemberOf" if transitive else "memberOf"
341
+ items = self.api.get_all(
342
+ f"/v1.0/{collection}/{object_id}/{relation}/microsoft.graph.group",
343
+ params={"$select": EntraGroup.SELECT, "$count": "true", "$top": "999"},
344
+ headers=_EVENTUAL,
345
+ )
346
+ groups = [EntraGroup.from_json(item) for item in items]
347
+ return sorted(groups, key=lambda group: group.display_name.casefold())
348
+
349
+ def _named(self, path: str, name: str, select: str) -> Iterator[dict[str, Any]]:
350
+ return self.api.get_all(
351
+ path, params={"$filter": f"displayName eq {odata_string(name)}", "$select": select}
352
+ )
353
+
354
+ def _get_one(self, path: str, select: str | None, missing: str) -> dict[str, Any]:
355
+ try:
356
+ return self.api.get(path, params={"$select": select} if select else None)
357
+ except ApiError as exc:
358
+ if exc.status == 404:
359
+ raise NotFoundError(missing) from None
360
+ raise
361
+
362
+
363
+ def expiring(
364
+ credentials: Iterable[AppCredential],
365
+ within: timedelta,
366
+ *,
367
+ now: datetime,
368
+ include_expired: bool = True,
369
+ ) -> list[AppCredential]:
370
+ """Credentials that end within ``within`` of ``now`` (and expired ones), soonest first."""
371
+ selected = [
372
+ credential
373
+ for credential in credentials
374
+ if credential.ends is not None
375
+ and credential.ends - now <= within
376
+ and (include_expired or credential.ends >= now)
377
+ ]
378
+ return sorted(selected, key=lambda credential: credential.ends or now)
379
+
380
+
381
+ def _credentials(
382
+ item: dict[str, Any], owner_kind: Literal["application", "service principal"]
383
+ ) -> list[AppCredential]:
384
+ found: list[AppCredential] = []
385
+ for key, kind in _CREDENTIAL_KINDS:
386
+ entries = item.get(key)
387
+ for entry in entries if isinstance(entries, list) else []:
388
+ if not isinstance(entry, dict):
389
+ continue
390
+ found.append(
391
+ AppCredential(
392
+ owner_kind=owner_kind,
393
+ owner_name=str(item.get("displayName") or ""),
394
+ owner_id=str(item.get("id") or ""),
395
+ app_id=str(item.get("appId") or ""),
396
+ kind=kind,
397
+ name=str(entry.get("displayName") or entry.get("hint") or ""),
398
+ key_id=str(entry.get("keyId") or ""),
399
+ starts=parse_datetime(entry.get("startDateTime")),
400
+ ends=parse_datetime(entry.get("endDateTime")),
401
+ raw=dict(entry),
402
+ )
403
+ )
404
+ return found
405
+
406
+
407
+ def _one(items: list[T], ref: str, singular: str, plural: str) -> T:
408
+ if not items:
409
+ raise NotFoundError(f"no {singular} is named {ref!r}")
410
+ if len(items) > 1:
411
+ ids = ", ".join(str(getattr(item, "id", "")) for item in items)
412
+ raise AmbiguousError(
413
+ f"{len(items)} {plural} are named {ref!r}: {ids}", hint="pass the object id instead"
414
+ )
415
+ return items[0]
416
+
417
+
418
+ def _object_id(value: EntraDevice | EntraGroup | EntraUser | DirectoryObject | str) -> str:
419
+ object_id = value if isinstance(value, str) else value.id
420
+ if not is_guid(object_id):
421
+ raise LdoError(f"not an Entra object id: {object_id!r}")
422
+ return object_id.strip()