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,162 @@
1
+ """Consumption Logic Apps in Azure: read deployed workflows, and ask Azure to validate one.
2
+
3
+ Validating goes to the resource provider's validate endpoint, which type-checks a whole
4
+ definition and gives the provider's own verdict while creating and costing nothing. It
5
+ is the authority the offline checks defer to: whether a property is accepted on the API
6
+ version, whether an expression type-checks, whether a connector action is well formed.
7
+ It validates the definition, not the estate around it, so a missing dispatch target
8
+ (``NestedWorkflowNotFound``) is for ``deploy_order`` to prevent, not for this to catch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any, Self
16
+ from urllib.parse import quote
17
+
18
+ import requests
19
+
20
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
21
+ from libre_devops_helpers.core.errors import ApiError, InputError, NotFoundError
22
+ from libre_devops_helpers.core.http import ApiClient
23
+ from libre_devops_helpers.core.util import is_guid
24
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
25
+ from libre_devops_helpers.microsoft.config import Profile
26
+ from libre_devops_helpers.microsoft.logicapps.document import WorkflowDocument
27
+
28
+ API_VERSION = "2019-05-01"
29
+ _GROUP_API = "2021-04-01"
30
+ _NAME = re.compile(r"^[A-Za-z0-9._()-]{1,90}$")
31
+ _LOCATION = re.compile(r"^[a-z0-9]{2,40}$")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Validation:
36
+ """The provider's verdict on a definition."""
37
+
38
+ workflow: str
39
+ location: str
40
+ valid: bool
41
+ code: str | None
42
+ message: str
43
+
44
+
45
+ class LogicAppsClient:
46
+ """Reads Consumption Logic App workflows through ARM. Close it when done."""
47
+
48
+ def __init__(self, api: ApiClient) -> None:
49
+ self.api = api
50
+
51
+ @classmethod
52
+ def create(
53
+ cls,
54
+ tokens: TokenProvider,
55
+ tenant_id: str,
56
+ *,
57
+ arm_url: str = PUBLIC.arm_url,
58
+ verify: bool | str = True,
59
+ session: requests.Session | None = None,
60
+ ) -> Self:
61
+ api = ApiClient(
62
+ arm_url,
63
+ token_source(tokens, arm_url.rstrip("/") + "/", tenant_id),
64
+ name="Azure Resource Manager",
65
+ verify=verify,
66
+ session=session,
67
+ )
68
+ return cls(api)
69
+
70
+ @classmethod
71
+ def for_profile(
72
+ cls,
73
+ profile: Profile,
74
+ tokens: TokenProvider,
75
+ *,
76
+ verify: bool | str = True,
77
+ session: requests.Session | None = None,
78
+ ) -> Self:
79
+ return cls.create(
80
+ tokens, profile.tenant_id, arm_url=profile.cloud.arm_url, verify=verify, session=session
81
+ )
82
+
83
+ def close(self) -> None:
84
+ self.api.close()
85
+
86
+ def workflows(self, subscription: str, resource_group: str) -> list[dict[str, Any]]:
87
+ """Every Consumption workflow in the resource group, as ARM returns it."""
88
+ path = f"{_group_path(subscription, resource_group)}/providers/Microsoft.Logic/workflows"
89
+ return list(
90
+ self.api.get_all(path, params={"api-version": API_VERSION}, next_link="nextLink")
91
+ )
92
+
93
+ def workflow(self, subscription: str, resource_group: str, name: str) -> dict[str, Any]:
94
+ """One workflow, whole: its definition, parameter values and all."""
95
+ group = _group_path(subscription, resource_group)
96
+ path = f"{group}/providers/Microsoft.Logic/workflows/{_name(name)}"
97
+ try:
98
+ return self.api.get(path, params={"api-version": API_VERSION})
99
+ except ApiError as exc:
100
+ if exc.status == 404:
101
+ raise NotFoundError(f"no workflow {name!r} in {resource_group}") from None
102
+ raise
103
+
104
+ def location_of(self, subscription: str, resource_group: str) -> str:
105
+ """The resource group's region, where a workflow in it would live."""
106
+ group = self.api.get(
107
+ _group_path(subscription, resource_group), params={"api-version": _GROUP_API}
108
+ )
109
+ location = group.get("location")
110
+ if not isinstance(location, str) or not location:
111
+ raise ApiError(f"resource group {resource_group} has no location")
112
+ return location
113
+
114
+ def validate(
115
+ self,
116
+ document: WorkflowDocument,
117
+ *,
118
+ subscription: str,
119
+ resource_group: str,
120
+ location: str,
121
+ name: str | None = None,
122
+ ) -> Validation:
123
+ """Ask the provider whether it would accept ``document``; nothing is deployed."""
124
+ workflow = _name(name or document.name or "ldo-validate-probe")
125
+ if not _LOCATION.match(location):
126
+ raise InputError(f"{location!r} is not an Azure region name, such as uksouth")
127
+ # The body is a workflow resource: the definition, and the parameter VALUES beside
128
+ # it. A declaration with no value is exactly what the provider rejects.
129
+ properties: dict[str, Any] = {"definition": dict(document.definition)}
130
+ if document.parameter_values is not None:
131
+ properties["parameters"] = dict(document.parameter_values)
132
+ path = (
133
+ f"{_group_path(subscription, resource_group)}/providers/Microsoft.Logic"
134
+ f"/locations/{location}/workflows/{workflow}/validate"
135
+ )
136
+ try:
137
+ self.api.request(
138
+ "POST",
139
+ path,
140
+ params={"api-version": API_VERSION},
141
+ json_body={"location": location, "properties": properties},
142
+ allow_empty=True,
143
+ )
144
+ except ApiError as exc:
145
+ if exc.status == 400:
146
+ return Validation(workflow, location, False, exc.code, str(exc))
147
+ raise
148
+ return Validation(workflow, location, True, None, "accepted")
149
+
150
+
151
+ def _group_path(subscription: str, resource_group: str) -> str:
152
+ if not is_guid(subscription):
153
+ raise InputError(f"{subscription!r} is not a subscription id")
154
+ if not re.match(r"^[-\w._()]{1,90}$", resource_group) or resource_group.endswith("."):
155
+ raise InputError(f"{resource_group!r} is not a resource group name")
156
+ return f"/subscriptions/{subscription}/resourceGroups/{quote(resource_group)}"
157
+
158
+
159
+ def _name(name: str) -> str:
160
+ if not _NAME.match(name):
161
+ raise InputError(f"{name!r} is not a Logic App workflow name")
162
+ return name
@@ -0,0 +1,202 @@
1
+ """Reading a Consumption Logic App workflow document, whatever shape it arrives in.
2
+
3
+ A definition arrives in one of three shapes, and the difference matters:
4
+
5
+ - ``code-view``: the designer's code view, ``{"definition": {...}, "parameters": {...}}``.
6
+ - ``arm``: an ARM resource GET, ``{"properties": {"definition": {...}, ...}, ...}``.
7
+ - ``bare``: a template's bare definition, ``{"$schema": ..., "triggers": ..., "actions": ...}``.
8
+
9
+ A wrapper's ``parameters`` block holds VALUES; a bare definition's top-level
10
+ ``parameters`` holds its DECLARATIONS. Reading one as the other is the classic round-trip
11
+ trap, so the two are always kept apart here. A workflow definition has no top-level
12
+ ``definition`` or ``properties`` key of its own, so telling the shapes apart is exact.
13
+
14
+ Templates for Terraform's ``templatefile`` (``.json.tftpl``) are not JSON while they hold
15
+ ``${...}`` tokens; those are blanked first, so their shape can still be checked.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Iterator, Mapping
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from libre_devops_helpers.core.errors import InputError
27
+
28
+ TOKEN_MARK = "TFTPL_TOKEN" # what an unrendered ${...} becomes, so the text parses
29
+ SHAPES = ("code-view", "arm", "bare")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class WorkflowDocument:
34
+ """A workflow document, unwrapped: its definition, and the values beside it."""
35
+
36
+ source: str
37
+ name: str | None
38
+ shape: str
39
+ definition: Mapping[str, Any]
40
+ parameter_values: Mapping[str, Any] | None # None for a bare definition, which has none
41
+ text: str
42
+
43
+ @property
44
+ def declarations(self) -> Mapping[str, Any] | None:
45
+ """The parameters the definition declares, or None when it declares none."""
46
+ value = self.definition.get("parameters")
47
+ return value if isinstance(value, Mapping) else None
48
+
49
+ @property
50
+ def triggers(self) -> Mapping[str, Any]:
51
+ value = self.definition.get("triggers")
52
+ return value if isinstance(value, Mapping) else {}
53
+
54
+ @property
55
+ def actions(self) -> Mapping[str, Any]:
56
+ value = self.definition.get("actions")
57
+ return value if isinstance(value, Mapping) else {}
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ActionNode:
62
+ """One action, wherever it sits: ``path`` names it through its containers."""
63
+
64
+ name: str
65
+ path: str
66
+ action: Mapping[str, Any]
67
+
68
+
69
+ def token_safe_json(text: str) -> str:
70
+ """``text`` with each Terraform ``${...}`` token blanked, so it parses as JSON.
71
+
72
+ Where a token sits decides the replacement, which is why this scans rather than using
73
+ a regex: inside a JSON string it becomes bare text, but standing alone in value
74
+ position (``"interval": ${x}``) it needs quotes of its own. Escapes and brace nesting
75
+ are tracked, so a token holding braces is one unit. ``$${`` is templatefile's escaped
76
+ literal (it renders as ``${``), so it is not a token. Logic App expressions use
77
+ ``@{...}``, so a ``${...}`` is always a Terraform token here.
78
+ """
79
+ out: list[str] = []
80
+ in_string = False
81
+ index = 0
82
+ length = len(text)
83
+ while index < length:
84
+ char = text[index]
85
+ if in_string:
86
+ if char == "\\" and index + 1 < length:
87
+ out.append(text[index : index + 2]) # an escape carries its next character
88
+ index += 2
89
+ continue
90
+ if char == '"':
91
+ in_string = False
92
+ out.append(char)
93
+ index += 1
94
+ continue
95
+ elif char == '"':
96
+ in_string = True
97
+ out.append(char)
98
+ index += 1
99
+ continue
100
+ if text.startswith("$${", index):
101
+ out.append("${")
102
+ index += 3
103
+ continue
104
+ if text.startswith("${", index):
105
+ end = _token_end(text, index + 1)
106
+ if end is not None:
107
+ out.append(TOKEN_MARK if in_string else f'"{TOKEN_MARK}"')
108
+ index = end + 1
109
+ continue
110
+ out.append(char)
111
+ index += 1
112
+ return "".join(out)
113
+
114
+
115
+ def _token_end(text: str, opening: int) -> int | None:
116
+ """The index of the brace that closes the one at ``opening``, or None."""
117
+ depth = 0
118
+ for position in range(opening, len(text)):
119
+ if text[position] == "{":
120
+ depth += 1
121
+ elif text[position] == "}":
122
+ depth -= 1
123
+ if depth == 0:
124
+ return position
125
+ return None
126
+
127
+
128
+ def load(path: Path) -> WorkflowDocument:
129
+ """Read and unwrap the document at ``path`` (``.json`` or ``.json.tftpl``)."""
130
+ try:
131
+ text = path.read_text(encoding="utf-8-sig")
132
+ except FileNotFoundError:
133
+ raise InputError(f"Logic App definition file not found: {path}") from None
134
+ except OSError as exc:
135
+ raise InputError(f"cannot read {path}: {exc}") from None
136
+ return parse(text, str(path), default_name=workflow_name_from(path))
137
+
138
+
139
+ def parse(
140
+ text: str, source: str = "<string>", *, default_name: str | None = None
141
+ ) -> WorkflowDocument:
142
+ """Unwrap a document already read, from ``source`` (a path, or ``<string>``)."""
143
+ if not text.strip():
144
+ raise InputError(f"Logic App definition {source} is empty")
145
+ try:
146
+ parsed = json.loads(token_safe_json(text))
147
+ except ValueError as exc:
148
+ raise InputError(f"Logic App definition {source} is not JSON: {exc}") from None
149
+ if not isinstance(parsed, Mapping):
150
+ raise InputError(f"Logic App definition {source} is not a JSON object")
151
+ properties = parsed.get("properties")
152
+ if isinstance(properties, Mapping) and isinstance(properties.get("definition"), Mapping):
153
+ shape, definition, values = "arm", properties["definition"], properties.get("parameters")
154
+ elif isinstance(parsed.get("definition"), Mapping):
155
+ shape, definition, values = "code-view", parsed["definition"], parsed.get("parameters")
156
+ else:
157
+ shape, definition, values = "bare", parsed, None
158
+ name = parsed.get("name") if isinstance(parsed.get("name"), str) else default_name
159
+ return WorkflowDocument(
160
+ source=source,
161
+ name=name,
162
+ shape=shape,
163
+ definition=definition,
164
+ parameter_values=values if isinstance(values, Mapping) else None,
165
+ text=text,
166
+ )
167
+
168
+
169
+ def workflow_name_from(path: Path) -> str:
170
+ """``router`` for ``router.json`` or ``router.json.tftpl``."""
171
+ name = path.name
172
+ for suffix in (".tftpl", ".json"):
173
+ name = name.removesuffix(suffix)
174
+ return name
175
+
176
+
177
+ def action_nodes(actions: Mapping[str, Any] | None, prefix: str = "") -> Iterator[ActionNode]:
178
+ """Every action, however deeply nested, each with its full path.
179
+
180
+ The workflow language nests actions: a Scope, Foreach and Until carry ``actions``; an
181
+ If adds ``else.actions``; a Switch carries ``cases.<name>.actions`` and
182
+ ``default.actions``. Reading only the top level sees a fraction of the workflow.
183
+ """
184
+ if not isinstance(actions, Mapping):
185
+ return
186
+ for name, action in actions.items():
187
+ if not isinstance(action, Mapping):
188
+ continue
189
+ path = f"{prefix}/{name}" if prefix else name
190
+ yield ActionNode(name, path, action)
191
+ containers: list[Any] = [action.get("actions")]
192
+ for branch in ("else", "default"):
193
+ node = action.get(branch)
194
+ if isinstance(node, Mapping):
195
+ containers.append(node.get("actions"))
196
+ cases = action.get("cases")
197
+ if isinstance(cases, Mapping):
198
+ containers.extend(
199
+ case.get("actions") for case in cases.values() if isinstance(case, Mapping)
200
+ )
201
+ for container in containers:
202
+ yield from action_nodes(container, path)
@@ -0,0 +1,39 @@
1
+ """Privileged Identity Management: eligible and active roles, requests, approvals, settings.
2
+
3
+ Covers the three PIM areas in one shape: Azure resource roles (through Azure Resource
4
+ Manager), Entra roles and PIM for Groups (through Microsoft Graph). Depends only on
5
+ ``core`` and the shared Microsoft layer. Every call reads; nothing is activated here.
6
+
7
+ Public API::
8
+
9
+ from libre_devops_helpers.microsoft.pim import AzurePimClient, GraphPimClient
10
+
11
+ with AzurePimClient.for_profile(profile, tokens) as azure:
12
+ eligible = azure.eligible() # the signed-in user's
13
+ with GraphPimClient.for_profile(profile, tokens) as graph:
14
+ pending = graph.role_requests(approver=True) # waiting on the signed-in user
15
+ """
16
+
17
+ from libre_devops_helpers.microsoft.pim.azure import AzurePimClient
18
+ from libre_devops_helpers.microsoft.pim.entra import GraphPimClient
19
+ from libre_devops_helpers.microsoft.pim.models import (
20
+ AREAS,
21
+ Area,
22
+ PimAssignment,
23
+ PimRequest,
24
+ PimSettings,
25
+ )
26
+ from libre_devops_helpers.microsoft.pim.permissions import REQUIREMENTS
27
+ from libre_devops_helpers.microsoft.pim.rules import settings_from_rules
28
+
29
+ __all__ = [
30
+ "AREAS",
31
+ "REQUIREMENTS",
32
+ "Area",
33
+ "AzurePimClient",
34
+ "GraphPimClient",
35
+ "PimAssignment",
36
+ "PimRequest",
37
+ "PimSettings",
38
+ "settings_from_rules",
39
+ ]
@@ -0,0 +1,238 @@
1
+ """PIM for Azure resources, through Azure Resource Manager.
2
+
3
+ Access rests on Azure RBAC, so the Azure CLI's token works; the tenant needs Microsoft
4
+ Entra ID P2 or ID Governance. "Mine" views are asked at the root scope with ARM's own
5
+ filters (``asTarget()``, ``asRequestor()``, ``asApprover()``); a named principal is asked
6
+ per subscription with ``assignedTo()``, which includes roles held through a group and
7
+ those inherited from above.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Mapping
13
+ from typing import Any, Self
14
+
15
+ import requests
16
+
17
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
18
+ from libre_devops_helpers.core.errors import LdoError, NotFoundError
19
+ from libre_devops_helpers.core.http import ApiClient
20
+ from libre_devops_helpers.core.util import is_guid, odata_string, parse_datetime
21
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
22
+ from libre_devops_helpers.microsoft.config import Profile
23
+ from libre_devops_helpers.microsoft.pim.models import PimAssignment, PimRequest, PimSettings
24
+ from libre_devops_helpers.microsoft.pim.rules import settings_from_rules
25
+
26
+ PIM_API = "2020-10-01"
27
+ ROLES_API = "2022-04-01"
28
+ _AUTHZ = "providers/Microsoft.Authorization"
29
+
30
+
31
+ class AzurePimClient:
32
+ """PIM for Azure resources. Close it (or use ``with``) when done."""
33
+
34
+ def __init__(self, api: ApiClient) -> None:
35
+ self.api = api
36
+
37
+ @classmethod
38
+ def create(
39
+ cls,
40
+ tokens: TokenProvider,
41
+ tenant_id: str,
42
+ *,
43
+ arm_url: str = PUBLIC.arm_url,
44
+ verify: bool | str = True,
45
+ session: requests.Session | None = None,
46
+ ) -> AzurePimClient:
47
+ api = ApiClient(
48
+ arm_url,
49
+ token_source(tokens, arm_url.rstrip("/") + "/", tenant_id),
50
+ name="Azure PIM",
51
+ verify=verify,
52
+ session=session,
53
+ )
54
+ return cls(api)
55
+
56
+ @classmethod
57
+ def for_profile(
58
+ cls,
59
+ profile: Profile,
60
+ tokens: TokenProvider,
61
+ *,
62
+ verify: bool | str = True,
63
+ session: requests.Session | None = None,
64
+ ) -> AzurePimClient:
65
+ return cls.create(
66
+ tokens, profile.tenant_id, arm_url=profile.cloud.arm_url, verify=verify, session=session
67
+ )
68
+
69
+ def close(self) -> None:
70
+ self.api.close()
71
+
72
+ def __enter__(self) -> Self:
73
+ return self
74
+
75
+ def __exit__(self, *exc_info: object) -> None:
76
+ self.close()
77
+
78
+ def eligible(
79
+ self, *, principal_id: str | None = None, scopes: Iterable[str] = ()
80
+ ) -> list[PimAssignment]:
81
+ """Eligible role assignments: the signed-in user's, or ``principal_id``'s."""
82
+ return self._instances("roleEligibilityScheduleInstances", "eligible", principal_id, scopes)
83
+
84
+ def active(
85
+ self, *, principal_id: str | None = None, scopes: Iterable[str] = ()
86
+ ) -> list[PimAssignment]:
87
+ """Active role assignments, activated through PIM or standing."""
88
+ return self._instances("roleAssignmentScheduleInstances", "active", principal_id, scopes)
89
+
90
+ def requests(
91
+ self, *, approver: bool = False, principal_id: str | None = None, scopes: Iterable[str] = ()
92
+ ) -> list[PimRequest]:
93
+ """Requests the signed-in user made, or ones waiting on them with ``approver``."""
94
+ if principal_id:
95
+ pairs = [
96
+ (scope, f"principalId eq {odata_string(_guid(principal_id))}")
97
+ for scope in _scopes(scopes)
98
+ ]
99
+ else:
100
+ pairs = [("", "asApprover()" if approver else "asRequestor()")]
101
+ found: dict[str, PimRequest] = {}
102
+ for scope, expression in pairs:
103
+ for item in self._list(scope, "roleAssignmentScheduleRequests", expression):
104
+ request = _request(item)
105
+ found.setdefault(request.id, request)
106
+ return sorted(found.values(), key=lambda item: str(item.created or ""), reverse=True)
107
+
108
+ def settings(self, role: str, scope: str) -> PimSettings:
109
+ """A role's PIM settings at ``scope`` (a subscription, resource group or resource)."""
110
+ scope = _scope(scope)
111
+ definitions = list(
112
+ self.api.get_all(
113
+ f"{scope}/{_AUTHZ}/roleDefinitions",
114
+ params={"api-version": ROLES_API, "$filter": f"roleName eq {odata_string(role)}"},
115
+ next_link="nextLink",
116
+ )
117
+ )
118
+ if not definitions:
119
+ raise NotFoundError(f"no Azure role is named {role!r} at {scope}")
120
+ definition_id = str(definitions[0].get("id") or "")
121
+ items = list(
122
+ self.api.get_all(
123
+ f"{scope}/{_AUTHZ}/roleManagementPolicyAssignments",
124
+ params={
125
+ "api-version": PIM_API,
126
+ "$filter": f"roleDefinitionId eq {odata_string(definition_id)}",
127
+ },
128
+ next_link="nextLink",
129
+ )
130
+ )
131
+ if not items:
132
+ raise NotFoundError(f"no PIM settings for {role!r} at {scope}")
133
+ properties = items[0].get("properties")
134
+ properties = properties if isinstance(properties, Mapping) else {}
135
+ rules = properties.get("effectiveRules") or []
136
+ return settings_from_rules("azure", role, scope, rules if isinstance(rules, list) else [])
137
+
138
+ def _instances(
139
+ self, collection: str, state: str, principal_id: str | None, scopes: Iterable[str]
140
+ ) -> list[PimAssignment]:
141
+ if principal_id:
142
+ expression = f"assignedTo({odata_string(_guid(principal_id))})"
143
+ pairs = [(scope, expression) for scope in _scopes(scopes)]
144
+ else:
145
+ pairs = [("", "asTarget()")]
146
+ found: dict[str, PimAssignment] = {}
147
+ for scope, expression in pairs:
148
+ for item in self._list(scope, collection, expression):
149
+ assignment = _assignment(item, state)
150
+ found.setdefault(str(item.get("id") or id(item)), assignment)
151
+ return sorted(found.values(), key=lambda item: (item.role.casefold(), item.scope))
152
+
153
+ def _list(self, scope: str, collection: str, expression: str) -> list[dict[str, Any]]:
154
+ return list(
155
+ self.api.get_all(
156
+ f"{scope}/{_AUTHZ}/{collection}",
157
+ params={"api-version": PIM_API, "$filter": expression},
158
+ next_link="nextLink",
159
+ )
160
+ )
161
+
162
+
163
+ def _assignment(item: Mapping[str, Any], state: str) -> PimAssignment:
164
+ properties = _map(item.get("properties"))
165
+ expanded = _map(properties.get("expandedProperties"))
166
+ principal = _map(expanded.get("principal"))
167
+ role = _map(expanded.get("roleDefinition"))
168
+ scope = _map(expanded.get("scope"))
169
+ return PimAssignment(
170
+ area="azure",
171
+ state="eligible" if state == "eligible" else "active",
172
+ role=str(role.get("displayName") or properties.get("roleDefinitionId") or ""),
173
+ scope=str(scope.get("displayName") or properties.get("scope") or ""),
174
+ principal_id=str(properties.get("principalId") or ""),
175
+ principal_name=str(principal.get("displayName") or principal.get("email") or ""),
176
+ member_type=str(properties.get("memberType") or ""),
177
+ assignment_type=str(properties.get("assignmentType") or ""),
178
+ starts=parse_datetime(properties.get("startDateTime")),
179
+ ends=parse_datetime(properties.get("endDateTime")),
180
+ raw=dict(item),
181
+ )
182
+
183
+
184
+ def _request(item: Mapping[str, Any]) -> PimRequest:
185
+ properties = _map(item.get("properties"))
186
+ expanded = _map(properties.get("expandedProperties"))
187
+ schedule = _map(properties.get("scheduleInfo"))
188
+ expiration = _map(schedule.get("expiration"))
189
+ ticket = _map(properties.get("ticketInfo"))
190
+ return PimRequest(
191
+ area="azure",
192
+ id=str(item.get("id") or ""),
193
+ action=str(properties.get("requestType") or ""),
194
+ status=str(properties.get("status") or ""),
195
+ role=str(_map(expanded.get("roleDefinition")).get("displayName") or ""),
196
+ scope=str(_map(expanded.get("scope")).get("displayName") or properties.get("scope") or ""),
197
+ principal_id=str(properties.get("principalId") or ""),
198
+ principal_name=str(_map(expanded.get("principal")).get("displayName") or ""),
199
+ justification=str(properties.get("justification") or ""),
200
+ created=parse_datetime(properties.get("createdOn")),
201
+ starts=parse_datetime(schedule.get("startDateTime")),
202
+ ends=parse_datetime(expiration.get("endDateTime")),
203
+ duration=str(expiration.get("duration") or ""),
204
+ ticket=str(ticket.get("ticketNumber") or ""),
205
+ raw=dict(item),
206
+ )
207
+
208
+
209
+ def _map(value: object) -> Mapping[str, Any]:
210
+ return value if isinstance(value, Mapping) else {}
211
+
212
+
213
+ def _guid(value: str) -> str:
214
+ if not is_guid(value):
215
+ raise LdoError(f"not an object id: {value!r}")
216
+ return value.strip().lower()
217
+
218
+
219
+ def _scope(value: str) -> str:
220
+ """An ARM scope, checked: '/subscriptions/<id>...' or a management group."""
221
+ scope = "/" + value.strip().strip("/")
222
+ if not (
223
+ scope.startswith("/subscriptions/") or scope.startswith("/providers/Microsoft.Management/")
224
+ ):
225
+ raise LdoError(
226
+ f"not an Azure scope: {value!r}",
227
+ hint="use /subscriptions/<id>[/resourceGroups/<name>...] or a management group",
228
+ )
229
+ if ".." in scope or "://" in scope:
230
+ raise LdoError(f"not an Azure scope: {value!r}")
231
+ return scope
232
+
233
+
234
+ def _scopes(values: Iterable[str]) -> list[str]:
235
+ scopes = [_scope(value) for value in values]
236
+ if not scopes:
237
+ raise LdoError("a named principal needs at least one scope to search")
238
+ return scopes