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