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