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