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,294 @@
|
|
|
1
|
+
"""PIM for Entra roles and for groups, through Microsoft Graph v1.0.
|
|
2
|
+
|
|
3
|
+
"Mine" views use Graph's ``filterByCurrentUser``, so they need a delegated token (the
|
|
4
|
+
Azure CLI's does not carry the scopes; a profile with ``auth = "interactive"`` or
|
|
5
|
+
``"device-code"`` does). A named principal is asked with a ``principalId`` filter, which
|
|
6
|
+
an app registration's application permissions can serve. Graph asks for a ReadWrite
|
|
7
|
+
scope even to list requests; this module still only ever reads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from typing import Any, Self
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from libre_devops_helpers.core.auth import TokenProvider, token_source
|
|
19
|
+
from libre_devops_helpers.core.errors import ApiError, LdoError, NotFoundError
|
|
20
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
21
|
+
from libre_devops_helpers.core.util import is_guid, odata_string, parse_datetime
|
|
22
|
+
from libre_devops_helpers.microsoft.clouds import PUBLIC
|
|
23
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
24
|
+
from libre_devops_helpers.microsoft.pim.models import (
|
|
25
|
+
Area,
|
|
26
|
+
PimAssignment,
|
|
27
|
+
PimRequest,
|
|
28
|
+
PimSettings,
|
|
29
|
+
)
|
|
30
|
+
from libre_devops_helpers.microsoft.pim.rules import settings_from_rules
|
|
31
|
+
|
|
32
|
+
_ROLES = "/v1.0/roleManagement/directory"
|
|
33
|
+
_GROUPS = "/v1.0/identityGovernance/privilegedAccess/group"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraphPimClient:
|
|
37
|
+
"""PIM for Entra roles and PIM for Groups. Close it (or use ``with``) when done."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, api: ApiClient) -> None:
|
|
40
|
+
self.api = api
|
|
41
|
+
self._role_names: dict[str, str] | None = None
|
|
42
|
+
self._group_names: dict[str, str] = {}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(
|
|
46
|
+
cls,
|
|
47
|
+
tokens: TokenProvider,
|
|
48
|
+
tenant_id: str,
|
|
49
|
+
*,
|
|
50
|
+
graph_url: str = PUBLIC.graph_url,
|
|
51
|
+
verify: bool | str = True,
|
|
52
|
+
session: requests.Session | None = None,
|
|
53
|
+
) -> GraphPimClient:
|
|
54
|
+
api = ApiClient(
|
|
55
|
+
graph_url,
|
|
56
|
+
token_source(tokens, graph_url, tenant_id),
|
|
57
|
+
name="Graph PIM",
|
|
58
|
+
verify=verify,
|
|
59
|
+
session=session,
|
|
60
|
+
)
|
|
61
|
+
return cls(api)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def for_profile(
|
|
65
|
+
cls,
|
|
66
|
+
profile: Profile,
|
|
67
|
+
tokens: TokenProvider,
|
|
68
|
+
*,
|
|
69
|
+
verify: bool | str = True,
|
|
70
|
+
session: requests.Session | None = None,
|
|
71
|
+
) -> GraphPimClient:
|
|
72
|
+
return cls.create(
|
|
73
|
+
tokens,
|
|
74
|
+
profile.tenant_id,
|
|
75
|
+
graph_url=profile.cloud.graph_url,
|
|
76
|
+
verify=verify,
|
|
77
|
+
session=session,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
self.api.close()
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> Self:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
# Entra roles --------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def role_eligible(self, *, principal_id: str | None = None) -> list[PimAssignment]:
|
|
92
|
+
items = self._mine_or_theirs(f"{_ROLES}/roleEligibilityScheduleInstances", principal_id)
|
|
93
|
+
return self._role_assignments(items, "eligible")
|
|
94
|
+
|
|
95
|
+
def role_active(self, *, principal_id: str | None = None) -> list[PimAssignment]:
|
|
96
|
+
items = self._mine_or_theirs(f"{_ROLES}/roleAssignmentScheduleInstances", principal_id)
|
|
97
|
+
return self._role_assignments(items, "active")
|
|
98
|
+
|
|
99
|
+
def role_requests(
|
|
100
|
+
self, *, approver: bool = False, principal_id: str | None = None
|
|
101
|
+
) -> list[PimRequest]:
|
|
102
|
+
path = f"{_ROLES}/roleAssignmentScheduleRequests"
|
|
103
|
+
items = self._mine_or_theirs(path, principal_id, on="approver" if approver else "principal")
|
|
104
|
+
names = self.role_names()
|
|
105
|
+
return _newest([_request("entra", item, names) for item in items])
|
|
106
|
+
|
|
107
|
+
def role_settings(self, role: str) -> PimSettings:
|
|
108
|
+
"""An Entra role's PIM settings, for the whole directory."""
|
|
109
|
+
role_id = self._role_id(role)
|
|
110
|
+
assignments = list(
|
|
111
|
+
self.api.get_all(
|
|
112
|
+
"/v1.0/policies/roleManagementPolicyAssignments",
|
|
113
|
+
params={
|
|
114
|
+
"$filter": "scopeId eq '/' and scopeType eq 'DirectoryRole' "
|
|
115
|
+
f"and roleDefinitionId eq {odata_string(role_id)}",
|
|
116
|
+
"$expand": "policy($expand=rules)",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
if not assignments:
|
|
121
|
+
raise NotFoundError(f"no PIM settings for the Entra role {role!r}")
|
|
122
|
+
return settings_from_rules(
|
|
123
|
+
"entra", self.role_names().get(role_id, role), "/", _rules(assignments[0])
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def role_names(self) -> dict[str, str]:
|
|
127
|
+
"""Every directory role definition's name by id, fetched once."""
|
|
128
|
+
if self._role_names is None:
|
|
129
|
+
self._role_names = {
|
|
130
|
+
str(item.get("id")): str(item.get("displayName") or "")
|
|
131
|
+
for item in self.api.get_all(
|
|
132
|
+
f"{_ROLES}/roleDefinitions", params={"$select": "id,displayName"}
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return self._role_names
|
|
136
|
+
|
|
137
|
+
# PIM for Groups -----------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def group_eligible(self, *, principal_id: str | None = None) -> list[PimAssignment]:
|
|
140
|
+
items = self._mine_or_theirs(f"{_GROUPS}/eligibilityScheduleInstances", principal_id)
|
|
141
|
+
return self._group_assignments(items, "eligible")
|
|
142
|
+
|
|
143
|
+
def group_active(self, *, principal_id: str | None = None) -> list[PimAssignment]:
|
|
144
|
+
items = self._mine_or_theirs(f"{_GROUPS}/assignmentScheduleInstances", principal_id)
|
|
145
|
+
return self._group_assignments(items, "active")
|
|
146
|
+
|
|
147
|
+
def group_requests(
|
|
148
|
+
self, *, approver: bool = False, principal_id: str | None = None
|
|
149
|
+
) -> list[PimRequest]:
|
|
150
|
+
path = f"{_GROUPS}/assignmentScheduleRequests"
|
|
151
|
+
items = self._mine_or_theirs(path, principal_id, on="approver" if approver else "principal")
|
|
152
|
+
return _newest([self._group_request(item) for item in items])
|
|
153
|
+
|
|
154
|
+
def group_settings(self, group_id: str, access: str = "member") -> PimSettings:
|
|
155
|
+
"""A group's PIM settings for its members (``member``) or owners (``owner``)."""
|
|
156
|
+
group_id = _guid(group_id)
|
|
157
|
+
assignments = list(
|
|
158
|
+
self.api.get_all(
|
|
159
|
+
"/v1.0/policies/roleManagementPolicyAssignments",
|
|
160
|
+
params={
|
|
161
|
+
"$filter": f"scopeId eq {odata_string(group_id)} and scopeType eq 'Group' "
|
|
162
|
+
f"and roleDefinitionId eq {odata_string(access)}",
|
|
163
|
+
"$expand": "policy($expand=rules)",
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
if not assignments:
|
|
168
|
+
raise NotFoundError(f"no PIM settings for group {group_id} ({access})")
|
|
169
|
+
return settings_from_rules(
|
|
170
|
+
"groups", access, self.group_name(group_id), _rules(assignments[0])
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def group_name(self, group_id: str) -> str:
|
|
174
|
+
"""A group's display name, cached; its id when the name cannot be read."""
|
|
175
|
+
if not is_guid(group_id):
|
|
176
|
+
# Never build a path from something that is not an object id.
|
|
177
|
+
return group_id
|
|
178
|
+
if group_id not in self._group_names:
|
|
179
|
+
try:
|
|
180
|
+
data = self.api.get(f"/v1.0/groups/{group_id}", params={"$select": "displayName"})
|
|
181
|
+
self._group_names[group_id] = str(data.get("displayName") or group_id)
|
|
182
|
+
except ApiError:
|
|
183
|
+
self._group_names[group_id] = group_id
|
|
184
|
+
return self._group_names[group_id]
|
|
185
|
+
|
|
186
|
+
# Helpers ------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _mine_or_theirs(
|
|
189
|
+
self, path: str, principal_id: str | None, *, on: str = "principal"
|
|
190
|
+
) -> list[dict[str, Any]]:
|
|
191
|
+
if principal_id:
|
|
192
|
+
params = {"$filter": f"principalId eq {odata_string(_guid(principal_id))}"}
|
|
193
|
+
return list(self.api.get_all(path, params=params))
|
|
194
|
+
return list(self.api.get_all(f"{path}/filterByCurrentUser(on='{on}')"))
|
|
195
|
+
|
|
196
|
+
def _role_id(self, role: str) -> str:
|
|
197
|
+
if is_guid(role):
|
|
198
|
+
return role.strip().lower()
|
|
199
|
+
wanted = role.strip().casefold()
|
|
200
|
+
for role_id, name in self.role_names().items():
|
|
201
|
+
if name.casefold() == wanted:
|
|
202
|
+
return role_id
|
|
203
|
+
raise NotFoundError(f"no Entra role is named {role!r}")
|
|
204
|
+
|
|
205
|
+
def _role_assignments(self, items: list[dict[str, Any]], state: str) -> list[PimAssignment]:
|
|
206
|
+
names = self.role_names()
|
|
207
|
+
found = [
|
|
208
|
+
PimAssignment(
|
|
209
|
+
area="entra",
|
|
210
|
+
state="eligible" if state == "eligible" else "active",
|
|
211
|
+
role=names.get(
|
|
212
|
+
str(item.get("roleDefinitionId")), str(item.get("roleDefinitionId") or "")
|
|
213
|
+
),
|
|
214
|
+
scope=str(item.get("directoryScopeId") or "/"),
|
|
215
|
+
principal_id=str(item.get("principalId") or ""),
|
|
216
|
+
principal_name="",
|
|
217
|
+
member_type=str(item.get("memberType") or ""),
|
|
218
|
+
assignment_type=str(item.get("assignmentType") or ""),
|
|
219
|
+
starts=parse_datetime(item.get("startDateTime")),
|
|
220
|
+
ends=parse_datetime(item.get("endDateTime")),
|
|
221
|
+
raw=dict(item),
|
|
222
|
+
)
|
|
223
|
+
for item in items
|
|
224
|
+
]
|
|
225
|
+
return sorted(found, key=lambda item: item.role.casefold())
|
|
226
|
+
|
|
227
|
+
def _group_assignments(self, items: list[dict[str, Any]], state: str) -> list[PimAssignment]:
|
|
228
|
+
found = [
|
|
229
|
+
PimAssignment(
|
|
230
|
+
area="groups",
|
|
231
|
+
state="eligible" if state == "eligible" else "active",
|
|
232
|
+
role=str(item.get("accessId") or ""),
|
|
233
|
+
scope=self.group_name(str(item.get("groupId") or "")),
|
|
234
|
+
principal_id=str(item.get("principalId") or ""),
|
|
235
|
+
principal_name="",
|
|
236
|
+
member_type=str(item.get("memberType") or ""),
|
|
237
|
+
assignment_type=str(item.get("assignmentType") or ""),
|
|
238
|
+
starts=parse_datetime(item.get("startDateTime")),
|
|
239
|
+
ends=parse_datetime(item.get("endDateTime")),
|
|
240
|
+
raw=dict(item),
|
|
241
|
+
)
|
|
242
|
+
for item in items
|
|
243
|
+
]
|
|
244
|
+
return sorted(found, key=lambda item: (item.scope.casefold(), item.role))
|
|
245
|
+
|
|
246
|
+
def _group_request(self, item: Mapping[str, Any]) -> PimRequest:
|
|
247
|
+
return replace(
|
|
248
|
+
_request("groups", item, {}),
|
|
249
|
+
role=str(item.get("accessId") or ""),
|
|
250
|
+
scope=self.group_name(str(item.get("groupId") or "")),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _request(area: Area, item: Mapping[str, Any], role_names: Mapping[str, str]) -> PimRequest:
|
|
255
|
+
schedule = item.get("scheduleInfo")
|
|
256
|
+
schedule = schedule if isinstance(schedule, Mapping) else {}
|
|
257
|
+
expiration = schedule.get("expiration")
|
|
258
|
+
expiration = expiration if isinstance(expiration, Mapping) else {}
|
|
259
|
+
ticket = item.get("ticketInfo")
|
|
260
|
+
ticket = ticket if isinstance(ticket, Mapping) else {}
|
|
261
|
+
role_id = str(item.get("roleDefinitionId") or "")
|
|
262
|
+
return PimRequest(
|
|
263
|
+
area=area,
|
|
264
|
+
id=str(item.get("id") or ""),
|
|
265
|
+
action=str(item.get("action") or ""),
|
|
266
|
+
status=str(item.get("status") or ""),
|
|
267
|
+
role=role_names.get(role_id, role_id),
|
|
268
|
+
scope=str(item.get("directoryScopeId") or "/"),
|
|
269
|
+
principal_id=str(item.get("principalId") or ""),
|
|
270
|
+
principal_name="",
|
|
271
|
+
justification=str(item.get("justification") or ""),
|
|
272
|
+
created=parse_datetime(item.get("createdDateTime")),
|
|
273
|
+
starts=parse_datetime(schedule.get("startDateTime")),
|
|
274
|
+
ends=parse_datetime(expiration.get("endDateTime")),
|
|
275
|
+
duration=str(expiration.get("duration") or ""),
|
|
276
|
+
ticket=str(ticket.get("ticketNumber") or ""),
|
|
277
|
+
raw=dict(item),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _rules(assignment: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
282
|
+
policy = assignment.get("policy")
|
|
283
|
+
rules = policy.get("rules") if isinstance(policy, Mapping) else None
|
|
284
|
+
return [rule for rule in rules if isinstance(rule, Mapping)] if isinstance(rules, list) else []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _newest(requests_: list[PimRequest]) -> list[PimRequest]:
|
|
288
|
+
return sorted(requests_, key=lambda item: str(item.created or ""), reverse=True)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _guid(value: str) -> str:
|
|
292
|
+
if not is_guid(value):
|
|
293
|
+
raise LdoError(f"not an object id: {value!r}")
|
|
294
|
+
return value.strip().lower()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""PIM records, in one shape across Azure resources, Entra roles and PIM for Groups.
|
|
2
|
+
|
|
3
|
+
Each API names things its own way (ARM's ``properties``, Graph's ``roleDefinitionId`` or
|
|
4
|
+
``groupId`` and ``accessId``); the clients convert their replies into these, so the CLI
|
|
5
|
+
shows all three areas in one table.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
Area = Literal["azure", "entra", "groups"]
|
|
16
|
+
AREAS: tuple[Area, ...] = ("azure", "entra", "groups")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PimAssignment:
|
|
21
|
+
"""A role someone is eligible for, or holds now.
|
|
22
|
+
|
|
23
|
+
``role`` is the role's name (for PIM for Groups, ``member`` or ``owner`` of the group).
|
|
24
|
+
``scope`` is where it applies: an ARM scope, a directory scope, or the group.
|
|
25
|
+
``member_type`` is ``Direct``, ``Group`` (through a group) or ``Inherited`` (from a
|
|
26
|
+
scope above). ``assignment_type`` is ``Activated`` (through PIM) or ``Assigned``
|
|
27
|
+
(standing) for active roles, and empty for eligible ones.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
area: Area
|
|
31
|
+
state: Literal["eligible", "active"]
|
|
32
|
+
role: str
|
|
33
|
+
scope: str
|
|
34
|
+
principal_id: str
|
|
35
|
+
principal_name: str
|
|
36
|
+
member_type: str
|
|
37
|
+
assignment_type: str
|
|
38
|
+
starts: datetime | None
|
|
39
|
+
ends: datetime | None
|
|
40
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def permanent(self) -> bool:
|
|
44
|
+
"""True when nothing ends it: standing access, the thing PIM exists to reduce."""
|
|
45
|
+
return self.ends is None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def activated(self) -> bool:
|
|
49
|
+
return self.assignment_type.casefold() == "activated"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class PimRequest:
|
|
54
|
+
"""A request to activate, assign or remove a role, and where it has got to."""
|
|
55
|
+
|
|
56
|
+
area: Area
|
|
57
|
+
id: str
|
|
58
|
+
action: str
|
|
59
|
+
status: str
|
|
60
|
+
role: str
|
|
61
|
+
scope: str
|
|
62
|
+
principal_id: str
|
|
63
|
+
principal_name: str
|
|
64
|
+
justification: str
|
|
65
|
+
created: datetime | None
|
|
66
|
+
starts: datetime | None
|
|
67
|
+
ends: datetime | None
|
|
68
|
+
duration: str
|
|
69
|
+
ticket: str
|
|
70
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def pending(self) -> bool:
|
|
74
|
+
return self.status.casefold() == "pendingapproval"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class PimSettings:
|
|
79
|
+
"""A role's PIM settings: what activating it takes, and how long it lasts."""
|
|
80
|
+
|
|
81
|
+
area: Area
|
|
82
|
+
role: str
|
|
83
|
+
scope: str
|
|
84
|
+
max_activation: str
|
|
85
|
+
requires_mfa: bool
|
|
86
|
+
requires_justification: bool
|
|
87
|
+
requires_ticket: bool
|
|
88
|
+
requires_approval: bool
|
|
89
|
+
approvers: tuple[str, ...]
|
|
90
|
+
authentication_context: str
|
|
91
|
+
eligible_expiry: str
|
|
92
|
+
active_expiry: str
|
|
93
|
+
raw: tuple[Mapping[str, Any], ...] = field(default=(), compare=False, repr=False)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What each PIM feature needs from a Graph token, as Graph itself reports it.
|
|
2
|
+
|
|
3
|
+
Graph asks for a ReadWrite scope even to list requests. PIM for Azure resources goes
|
|
4
|
+
through ARM and rests on Azure RBAC instead, so it has no requirements here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
8
|
+
|
|
9
|
+
_ROLE_MANAGEMENT = (
|
|
10
|
+
"RoleManagement.Read.Directory",
|
|
11
|
+
"RoleManagement.Read.All",
|
|
12
|
+
"RoleManagement.ReadWrite.Directory",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
REQUIREMENTS = (
|
|
16
|
+
Requirement(
|
|
17
|
+
"pim entra eligible",
|
|
18
|
+
"graph",
|
|
19
|
+
(
|
|
20
|
+
(
|
|
21
|
+
"RoleEligibilitySchedule.Read.Directory",
|
|
22
|
+
"RoleEligibilitySchedule.ReadWrite.Directory",
|
|
23
|
+
*_ROLE_MANAGEMENT,
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
Requirement(
|
|
28
|
+
"pim entra active",
|
|
29
|
+
"graph",
|
|
30
|
+
(
|
|
31
|
+
(
|
|
32
|
+
"RoleAssignmentSchedule.Read.Directory",
|
|
33
|
+
"RoleAssignmentSchedule.ReadWrite.Directory",
|
|
34
|
+
*_ROLE_MANAGEMENT,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
Requirement(
|
|
39
|
+
"pim entra requests",
|
|
40
|
+
"graph",
|
|
41
|
+
(("RoleAssignmentSchedule.ReadWrite.Directory", "RoleManagement.ReadWrite.Directory"),),
|
|
42
|
+
),
|
|
43
|
+
Requirement(
|
|
44
|
+
"pim entra settings",
|
|
45
|
+
"graph",
|
|
46
|
+
(
|
|
47
|
+
(
|
|
48
|
+
"RoleManagementPolicy.Read.Directory",
|
|
49
|
+
"RoleManagementPolicy.ReadWrite.Directory",
|
|
50
|
+
*_ROLE_MANAGEMENT,
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
Requirement(
|
|
55
|
+
"pim groups eligible",
|
|
56
|
+
"graph",
|
|
57
|
+
(
|
|
58
|
+
(
|
|
59
|
+
"PrivilegedEligibilitySchedule.Read.AzureADGroup",
|
|
60
|
+
"PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup",
|
|
61
|
+
"PrivilegedAccess.Read.AzureADGroup",
|
|
62
|
+
"PrivilegedAccess.ReadWrite.AzureADGroup",
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
Requirement(
|
|
67
|
+
"pim groups active",
|
|
68
|
+
"graph",
|
|
69
|
+
(
|
|
70
|
+
(
|
|
71
|
+
"PrivilegedAssignmentSchedule.Read.AzureADGroup",
|
|
72
|
+
"PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup",
|
|
73
|
+
"PrivilegedAccess.Read.AzureADGroup",
|
|
74
|
+
"PrivilegedAccess.ReadWrite.AzureADGroup",
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
Requirement(
|
|
79
|
+
"pim groups requests",
|
|
80
|
+
"graph",
|
|
81
|
+
(
|
|
82
|
+
(
|
|
83
|
+
"PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup",
|
|
84
|
+
"PrivilegedAccess.ReadWrite.AzureADGroup",
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
Requirement(
|
|
89
|
+
"pim groups settings",
|
|
90
|
+
"graph",
|
|
91
|
+
(
|
|
92
|
+
(
|
|
93
|
+
"RoleManagementPolicy.Read.AzureADGroup",
|
|
94
|
+
"RoleManagementPolicy.ReadWrite.AzureADGroup",
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Read a PIM role management policy's rules into ``PimSettings``.
|
|
2
|
+
|
|
3
|
+
Azure resources (ARM ``effectiveRules``) and Graph (``policy.rules``) use the same rule
|
|
4
|
+
ids, such as ``Expiration_EndUser_Assignment`` for how long an activation may last, so
|
|
5
|
+
one reader serves all three areas.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from libre_devops_helpers.microsoft.pim.models import Area, PimSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def settings_from_rules(
|
|
17
|
+
area: Area, role: str, scope: str, rules: Iterable[Mapping[str, Any]]
|
|
18
|
+
) -> PimSettings:
|
|
19
|
+
by_id = {str(rule.get("id") or ""): rule for rule in rules if isinstance(rule, Mapping)}
|
|
20
|
+
activation = by_id.get("Expiration_EndUser_Assignment", {})
|
|
21
|
+
enablement = by_id.get("Enablement_EndUser_Assignment", {})
|
|
22
|
+
approval = by_id.get("Approval_EndUser_Assignment", {})
|
|
23
|
+
context = by_id.get("AuthenticationContext_EndUser_Assignment", {})
|
|
24
|
+
enabled = {str(item).casefold() for item in enablement.get("enabledRules") or []}
|
|
25
|
+
setting = approval.get("setting") if isinstance(approval.get("setting"), Mapping) else {}
|
|
26
|
+
return PimSettings(
|
|
27
|
+
area=area,
|
|
28
|
+
role=role,
|
|
29
|
+
scope=scope,
|
|
30
|
+
max_activation=str(activation.get("maximumDuration") or ""),
|
|
31
|
+
requires_mfa="multifactorauthentication" in enabled,
|
|
32
|
+
requires_justification="justification" in enabled,
|
|
33
|
+
requires_ticket="ticketing" in enabled,
|
|
34
|
+
requires_approval=setting.get("isApprovalRequired") is True,
|
|
35
|
+
approvers=tuple(_approvers(setting)),
|
|
36
|
+
authentication_context=str(context.get("claimValue") or "")
|
|
37
|
+
if context.get("isEnabled") is True
|
|
38
|
+
else "",
|
|
39
|
+
eligible_expiry=_expiry(by_id.get("Expiration_Admin_Eligibility", {})),
|
|
40
|
+
active_expiry=_expiry(by_id.get("Expiration_Admin_Assignment", {})),
|
|
41
|
+
raw=tuple(by_id.values()),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _approvers(setting: Mapping[str, Any]) -> list[str]:
|
|
46
|
+
found: list[str] = []
|
|
47
|
+
for stage in setting.get("approvalStages") or []:
|
|
48
|
+
if not isinstance(stage, Mapping):
|
|
49
|
+
continue
|
|
50
|
+
for approver in stage.get("primaryApprovers") or []:
|
|
51
|
+
if isinstance(approver, Mapping):
|
|
52
|
+
found.append(
|
|
53
|
+
str(
|
|
54
|
+
approver.get("description")
|
|
55
|
+
or approver.get("userId")
|
|
56
|
+
or approver.get("groupId")
|
|
57
|
+
or approver.get("id")
|
|
58
|
+
or ""
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return [name for name in found if name]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _expiry(rule: Mapping[str, Any]) -> str:
|
|
65
|
+
"""``permanent allowed``, the maximum duration, or empty when the rule is absent."""
|
|
66
|
+
if not rule:
|
|
67
|
+
return ""
|
|
68
|
+
if rule.get("isExpirationRequired") is False:
|
|
69
|
+
return "permanent allowed"
|
|
70
|
+
return str(rule.get("maximumDuration") or "required")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The Azure CLI as a subprocess: JSON output and ``az``'s own error shapes and hints.
|
|
2
|
+
|
|
3
|
+
Shared by ``microsoft.auth.AzureCliCredential`` (tokens) and ``microsoft.azcli``
|
|
4
|
+
(accounts and context switching). The subprocess handling itself is core's
|
|
5
|
+
``CommandRunner``; this adds what is particular to ``az``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.core.errors import CommandError
|
|
13
|
+
from libre_devops_helpers.core.process import CommandRunner, Runner
|
|
14
|
+
|
|
15
|
+
# az prefixes an unexpected failure with this line, then dumps a Python traceback.
|
|
16
|
+
_UNEXPECTED = "The command failed with an unexpected error. Here is the traceback:"
|
|
17
|
+
INSTALL_HINT = "install it: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AzCliError(CommandError):
|
|
21
|
+
"""The Azure CLI is missing, or a command it ran failed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AzureCliRunner(CommandRunner):
|
|
25
|
+
"""Runs ``az`` commands with JSON output and checks their exit codes."""
|
|
26
|
+
|
|
27
|
+
error_type = AzCliError
|
|
28
|
+
|
|
29
|
+
def __init__(self, executable: str | None = None, *, runner: Runner | None = None) -> None:
|
|
30
|
+
options = {"runner": runner} if runner is not None else {}
|
|
31
|
+
super().__init__("az", executable, install_hint=INSTALL_HINT, **options)
|
|
32
|
+
|
|
33
|
+
def run_json(self, *args: str) -> Any:
|
|
34
|
+
"""Run ``az`` with JSON output and return the parsed value (None for no output)."""
|
|
35
|
+
return super().run_json(*args, "--output", "json", "--only-show-errors")
|
|
36
|
+
|
|
37
|
+
def clean_stderr(self, stderr: str | None) -> str:
|
|
38
|
+
return clean_stderr(stderr)
|
|
39
|
+
|
|
40
|
+
def hint_for(self, detail: str) -> str | None:
|
|
41
|
+
return hint_for(detail)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean_stderr(stderr: str | None) -> str:
|
|
45
|
+
"""The error message from ``az`` stderr, without any Python traceback after it."""
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
for raw in (stderr or "").splitlines():
|
|
48
|
+
line = raw.strip()
|
|
49
|
+
if line.startswith("Traceback (most recent call last)"):
|
|
50
|
+
break
|
|
51
|
+
line = line.removeprefix("ERROR: ")
|
|
52
|
+
if line and line != _UNEXPECTED:
|
|
53
|
+
lines.append(line)
|
|
54
|
+
return " ".join(lines)[:1000]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def signed_out(message: str) -> bool:
|
|
58
|
+
"""True when an ``az`` error means there is no usable session."""
|
|
59
|
+
return "az login" in message
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hint_for(detail: str) -> str | None:
|
|
63
|
+
"""A next step for a failed ``az`` command, when the message points to one."""
|
|
64
|
+
if "Unable to get authority configuration" in detail:
|
|
65
|
+
return (
|
|
66
|
+
"no such tenant: check the tenant id (for a profile, its tenant_id in the config file)"
|
|
67
|
+
)
|
|
68
|
+
if signed_out(detail) or "AADSTS" in detail:
|
|
69
|
+
return "the Azure CLI session is missing or expired; sign in again (az login --tenant <id>)"
|
|
70
|
+
return None
|