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,103 @@
1
+ """Access tokens and token providers.
2
+
3
+ A token provider is anything with ``get_token(resource, tenant_id)``. Each vendor layer
4
+ supplies its own credentials (``microsoft.auth`` for Entra ID), and its service modules
5
+ accept any provider, so none of them depends on where a token comes from.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime, timedelta
14
+ from typing import Protocol
15
+
16
+
17
+ def utc_now() -> datetime:
18
+ return datetime.now(UTC)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AccessToken:
23
+ """A bearer token. ``token`` is left out of repr so it cannot leak into logs."""
24
+
25
+ token: str = field(repr=False)
26
+ expires_on: datetime
27
+ tenant_id: str
28
+ resource: str
29
+
30
+ def expires_within(self, window: timedelta, *, now: datetime | None = None) -> bool:
31
+ """True when the token expires within ``window`` of ``now``."""
32
+ return self.expires_on - (now or utc_now()) <= window
33
+
34
+
35
+ class TokenProvider(Protocol):
36
+ """Anything that can produce an access token for a resource in a tenant."""
37
+
38
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
39
+ """An access token for ``resource`` in ``tenant_id``."""
40
+
41
+
42
+ class CachingTokenProvider:
43
+ """Wraps a provider and reuses each token until it is close to expiry.
44
+
45
+ The cache is in memory only; tokens are never written to disk. It is safe to share
46
+ between threads, and a token is fetched once even when several threads ask at once.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ inner: TokenProvider,
52
+ *,
53
+ refresh_before: timedelta = timedelta(minutes=5),
54
+ clock: Callable[[], datetime] = utc_now,
55
+ ) -> None:
56
+ self._inner = inner
57
+ self._refresh_before = refresh_before
58
+ self._clock = clock
59
+ self._cache: dict[tuple[str, str], AccessToken] = {}
60
+ self._lock = threading.Lock()
61
+
62
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
63
+ key = (resource, tenant_id.lower())
64
+ with self._lock:
65
+ cached = self._cache.get(key)
66
+ if cached is None or cached.expires_within(self._refresh_before, now=self._clock()):
67
+ cached = self._inner.get_token(resource, tenant_id)
68
+ self._cache[key] = cached
69
+ return cached
70
+
71
+ def invalidate(self, resource: str, tenant_id: str) -> None:
72
+ """Forget the cached token, so the next request fetches a new one."""
73
+ with self._lock:
74
+ self._cache.pop((resource, tenant_id.lower()), None)
75
+
76
+
77
+ class BearerToken:
78
+ """A current bearer token each time it is called, as ApiClient expects.
79
+
80
+ ``refresh`` drops the provider's cached token (when it caches), so the next call
81
+ fetches a new one. ApiClient calls it once when an API answers 401.
82
+ """
83
+
84
+ def __init__(self, provider: TokenProvider, resource: str, tenant_id: str) -> None:
85
+ self._provider = provider
86
+ self._resource = resource
87
+ self._tenant_id = tenant_id
88
+
89
+ def __repr__(self) -> str:
90
+ return f"BearerToken(resource={self._resource!r}, tenant_id={self._tenant_id!r})"
91
+
92
+ def __call__(self) -> str:
93
+ return self._provider.get_token(self._resource, self._tenant_id).token
94
+
95
+ def refresh(self) -> None:
96
+ invalidate = getattr(self._provider, "invalidate", None)
97
+ if callable(invalidate):
98
+ invalidate(self._resource, self._tenant_id)
99
+
100
+
101
+ def token_source(provider: TokenProvider, resource: str, tenant_id: str) -> BearerToken:
102
+ """A zero-argument callable returning a current bearer token, as ApiClient expects."""
103
+ return BearerToken(provider, resource, tenant_id)
@@ -0,0 +1,67 @@
1
+ """Who this tool is, in one place.
2
+
3
+ Every name the running tool shows or reads comes from here: its command, the prefix of
4
+ its environment variables, its config directory, and how it introduces itself. To ship it
5
+ under another name, run ``just rebrand``. That rewrites these values together with the
6
+ names no module can hold, such as the Python package name in every import; see
7
+ ``scripts/rebrand.py`` and ``brand.toml``.
8
+ """
9
+
10
+ DISPLAY_NAME = "Libre DevOps Helpers"
11
+ COMMAND = "ldo"
12
+ # The name to install, from PyPI or a git URL.
13
+ DISTRIBUTION = "libre-devops-helpers"
14
+ ENV_PREFIX = "LDO"
15
+ CONFIG_DIR = "ldo"
16
+ REPOSITORY = "https://github.com/libre-devops/python-helpers"
17
+
18
+ # The welcome banner, drawn after the Libre DevOps unicorn. Plain ASCII, so it renders in
19
+ # any terminal and font. 'just rebrand --banner FILE' swaps it; --no-banner empties it.
20
+ # banner-start
21
+ BANNER = r"""
22
+ `.
23
+ `.`.
24
+ `. `. |`.
25
+ `. `. | `.
26
+ `. `. | /
27
+ `. `. | `.
28
+ `. .' `.
29
+ `..' \ `.
30
+ .' \ `.
31
+ .' __ \ `.
32
+ .' / / `.
33
+ .' / `.
34
+ .' / `.
35
+ .' - .- `.
36
+ `. .--''
37
+ `. .--'' |
38
+ `._.-'' |
39
+ |
40
+
41
+ _ ___ ___ ___ ___ ___ _____ _____ ___ ___
42
+ | | |_ _| _ ) _ \ __| | \| __\ \ / / _ \| _ \/ __|
43
+ | |__ | || _ \ / _| | |) | _| \ V / (_) | _/\__ \
44
+ |____|___|___/_|_\___| |___/|___| \_/ \___/|_| |___/
45
+ """
46
+ # banner-end
47
+
48
+
49
+ def env_var(name: str) -> str:
50
+ """The environment variable ``name`` under this tool's prefix, e.g. ``LDO_CONFIG``."""
51
+ return f"{ENV_PREFIX}_{name}"
52
+
53
+
54
+ def command(text: str) -> str:
55
+ """A command line to suggest to the person, e.g. ``'ldo config init'``, quoted."""
56
+ return f"'{COMMAND} {text}'"
57
+
58
+
59
+ def docs(page: str) -> str:
60
+ """The web address of a page in docs/, for a hint, e.g. ``docs("servicenow")``."""
61
+ return f"{REPOSITORY}/blob/main/docs/{page}.md"
62
+
63
+
64
+ CONFIG_ENV = env_var("CONFIG")
65
+ PROFILE_ENV = env_var("PROFILE")
66
+ LOG_FORMAT_ENV = env_var("LOG_FORMAT")
67
+ LOG_LEVEL_ENV = env_var("LOG_LEVEL")
@@ -0,0 +1,21 @@
1
+ """Whether a browser can be opened here, decided as the Azure CLI decides it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import webbrowser
7
+
8
+
9
+ def can_launch_browser() -> bool:
10
+ """False on a Linux machine with no browser to open (a headless server, say).
11
+
12
+ Windows and macOS always have one. On Linux, a browser registered with Python's
13
+ webbrowser module (a desktop session, or BROWSER set) counts.
14
+ """
15
+ if not sys.platform.startswith("linux"):
16
+ return True
17
+ try:
18
+ webbrowser.get()
19
+ except webbrowser.Error:
20
+ return False
21
+ return True
@@ -0,0 +1,159 @@
1
+ """The config file: one TOML file, with a section per vendor.
2
+
3
+ Its path is, in order: an explicit path, the ``LDO_CONFIG`` environment variable, then the
4
+ platform config directory (``~/.config/ldo/config.toml`` on Linux and macOS, under
5
+ ``%APPDATA%`` on Windows). It lives outside any repository because it describes
6
+ environments, not code, and it never holds a secret.
7
+
8
+ The top level holds what every vendor shares (``ca_bundle``). Each vendor layer reads and
9
+ validates its own section (``[microsoft]``, ...) with the field readers here, so every
10
+ section rejects unknown keys and bad values the same way.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import sys
18
+ import tomllib
19
+ from collections.abc import Iterable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from libre_devops_helpers.core import brand
25
+ from libre_devops_helpers.core.errors import ConfigError, ConfigNotFoundError
26
+ from libre_devops_helpers.core.util import is_guid
27
+
28
+ CONFIG_HEADER = f"""\
29
+ # {brand.DISPLAY_NAME} ({brand.COMMAND}) configuration. Each vendor has its own section;
30
+ # every key is described in {brand.docs("configuration")}
31
+ #
32
+ # Optional PEM bundle for a TLS-inspecting proxy, used for every HTTPS call. Without it,
33
+ # SSL_CERT_FILE or the bundled CA list is used.
34
+ # ca_bundle = "~/certs/proxy-ca.pem"
35
+ """
36
+
37
+ _NAME = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ConfigFile:
42
+ """The parsed file: shared settings, plus each vendor's raw section."""
43
+
44
+ path: Path
45
+ data: Mapping[str, Any] = field(default_factory=dict)
46
+ ca_bundle: Path | None = None
47
+
48
+ def section(self, name: str) -> Mapping[str, Any] | None:
49
+ """A vendor's section, or None when the file has none."""
50
+ value = self.data.get(name)
51
+ if value is None:
52
+ return None
53
+ if not isinstance(value, dict):
54
+ raise ConfigError(f"{self.path}: [{name}] must be a table")
55
+ return value
56
+
57
+
58
+ def default_config_path() -> Path:
59
+ """Where the config file lives when no explicit path is given."""
60
+ override = os.environ.get(brand.CONFIG_ENV)
61
+ if override:
62
+ return Path(override).expanduser()
63
+ if sys.platform == "win32":
64
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
65
+ else:
66
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
67
+ return base / brand.CONFIG_DIR / "config.toml"
68
+
69
+
70
+ def load_config_file(
71
+ path: Path | None = None, *, sections: Iterable[str] | None = None
72
+ ) -> ConfigFile:
73
+ """Read and parse the config file. Raises ConfigNotFoundError when it is absent.
74
+
75
+ ``sections`` names the vendor sections that may appear; anything else at the top
76
+ level is then an error, so a typo fails loudly. None skips that check, for a library
77
+ caller that only knows its own vendor.
78
+ """
79
+ path = path or default_config_path()
80
+ try:
81
+ text = path.read_text(encoding="utf-8")
82
+ except FileNotFoundError:
83
+ raise ConfigNotFoundError(
84
+ f"config file not found: {path}",
85
+ hint=f"create one with {brand.command('config init')}",
86
+ ) from None
87
+ except OSError as exc:
88
+ raise ConfigError(f"cannot read {path}: {exc}") from None
89
+ try:
90
+ data = tomllib.loads(text)
91
+ except tomllib.TOMLDecodeError as exc:
92
+ raise ConfigError(f"{path} is not valid TOML: {exc}") from None
93
+ return parse_config_file(data, path, sections=sections)
94
+
95
+
96
+ def parse_config_file(
97
+ data: Mapping[str, Any], path: Path, *, sections: Iterable[str] | None = None
98
+ ) -> ConfigFile:
99
+ """Validate already-parsed TOML's shared settings."""
100
+ where = str(path)
101
+ if sections is not None:
102
+ reject_unknown(data, frozenset({"ca_bundle", *sections}), where)
103
+ ca_bundle = data.get("ca_bundle")
104
+ if ca_bundle is not None and not isinstance(ca_bundle, str):
105
+ raise ConfigError(f"{where}: ca_bundle must be a path string")
106
+ return ConfigFile(
107
+ path=path,
108
+ data=dict(data),
109
+ ca_bundle=Path(ca_bundle).expanduser() if ca_bundle else None,
110
+ )
111
+
112
+
113
+ # Field readers for vendor sections --------------------------------------------------
114
+
115
+
116
+ def check_name(name: str, where: str) -> None:
117
+ """Profile and instance names are lowercase, so they are easy to type and complete."""
118
+ if not _NAME.match(name):
119
+ raise ConfigError(f"{where}: use lowercase letters, digits, '-' and '_' in names")
120
+
121
+
122
+ def table(value: object, where: str) -> Mapping[str, Any]:
123
+ if not isinstance(value, dict):
124
+ raise ConfigError(f"{where}: expected a table")
125
+ return value
126
+
127
+
128
+ def guid(data: Mapping[str, Any], key: str, where: str) -> str | None:
129
+ value = data.get(key)
130
+ if value is None:
131
+ return None
132
+ if not isinstance(value, str) or not is_guid(value):
133
+ raise ConfigError(f"{where}: {key} must be a GUID, got {value!r}")
134
+ return value.strip().lower()
135
+
136
+
137
+ def text(data: Mapping[str, Any], key: str, where: str) -> str | None:
138
+ value = data.get(key)
139
+ if value is None:
140
+ return None
141
+ if not isinstance(value, str):
142
+ raise ConfigError(f"{where}: {key} must be a string")
143
+ return value.strip()
144
+
145
+
146
+ def https_url(data: Mapping[str, Any], key: str, where: str) -> str | None:
147
+ # Tokens are attached to every request, so plain http is never acceptable.
148
+ value = text(data, key, where)
149
+ if value is not None and not value.startswith("https://"):
150
+ raise ConfigError(f"{where}: {key} must be an https:// URL")
151
+ return value.rstrip("/") if value else None
152
+
153
+
154
+ def reject_unknown(data: Mapping[str, Any], allowed: frozenset[str], where: str) -> None:
155
+ unknown = sorted(set(data) - allowed)
156
+ if unknown:
157
+ raise ConfigError(
158
+ f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(sorted(allowed))})"
159
+ )
@@ -0,0 +1,60 @@
1
+ """Encrypt bytes for the signed-in Windows account with DPAPI, through ctypes.
2
+
3
+ ``CryptProtectData`` ties the result to the Windows account (and this machine), so only
4
+ that account can decrypt it; the brand name is mixed in as extra entropy, so another
5
+ program running as the same account cannot decrypt it by asking DPAPI alone. Windows
6
+ only: the Windows CI job tests it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from libre_devops_helpers.core import brand
16
+
17
+ _ENTROPY = f"{brand.COMMAND} refresh tokens".encode()
18
+ _UI_FORBIDDEN = 0x01 # never show a prompt
19
+
20
+
21
+ class _Blob(ctypes.Structure):
22
+ _fields_ = [("size", ctypes.c_uint32), ("data", ctypes.POINTER(ctypes.c_char))]
23
+
24
+
25
+ def protect(data: bytes) -> bytes: # pragma: no cover - Windows only
26
+ """``data`` encrypted for the current Windows account."""
27
+ return _crypt(_library("crypt32").CryptProtectData, data)
28
+
29
+
30
+ def unprotect(data: bytes) -> bytes: # pragma: no cover - Windows only
31
+ """``data`` decrypted; OSError when it was encrypted for another account."""
32
+ return _crypt(_library("crypt32").CryptUnprotectData, data)
33
+
34
+
35
+ def _library(name: str) -> Any: # pragma: no cover - Windows only
36
+ # use_last_error keeps the call's error code, which a later call could overwrite.
37
+ return ctypes.WinDLL(name, use_last_error=True) # type: ignore[attr-defined]
38
+
39
+
40
+ def _crypt(function: Callable[..., Any], data: bytes) -> bytes: # pragma: no cover - Windows
41
+ source = ctypes.create_string_buffer(data, len(data))
42
+ entropy = ctypes.create_string_buffer(_ENTROPY, len(_ENTROPY))
43
+ blob_in = _Blob(len(data), ctypes.cast(source, ctypes.POINTER(ctypes.c_char)))
44
+ blob_entropy = _Blob(len(_ENTROPY), ctypes.cast(entropy, ctypes.POINTER(ctypes.c_char)))
45
+ blob_out = _Blob()
46
+ # Both functions take (in, description, entropy, reserved, prompt, flags, out).
47
+ if not function(
48
+ ctypes.byref(blob_in),
49
+ None,
50
+ ctypes.byref(blob_entropy),
51
+ None,
52
+ None,
53
+ _UI_FORBIDDEN,
54
+ ctypes.byref(blob_out),
55
+ ):
56
+ raise ctypes.WinError(ctypes.get_last_error()) # type: ignore[attr-defined]
57
+ try:
58
+ return ctypes.string_at(blob_out.data, blob_out.size)
59
+ finally:
60
+ _library("kernel32").LocalFree(blob_out.data)
@@ -0,0 +1,90 @@
1
+ """Exception types raised by this package.
2
+
3
+ Library code raises these and never exits the process. Only the ``cli`` subpackage
4
+ turns them into messages and exit codes, so importing code keeps control of its flow.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class LdoError(Exception):
11
+ """Base class for every error this package raises.
12
+
13
+ ``hint`` is an optional next step for the person running the command.
14
+ """
15
+
16
+ exit_code = 1
17
+
18
+ def __init__(self, message: str, *, hint: str | None = None) -> None:
19
+ super().__init__(message)
20
+ self.hint = hint
21
+
22
+
23
+ class ConfigError(LdoError):
24
+ """The config file is invalid, or a profile cannot be used."""
25
+
26
+
27
+ class ConfigNotFoundError(ConfigError):
28
+ """The config file does not exist."""
29
+
30
+
31
+ class InputError(LdoError):
32
+ """A list of names, a file of names, or a duration could not be read."""
33
+
34
+
35
+ class CommandError(LdoError):
36
+ """An external command line tool is missing, or a command it ran failed."""
37
+
38
+
39
+ class AuthError(LdoError):
40
+ """A credential could not produce an access token."""
41
+
42
+
43
+ class ReauthRequired(AuthError):
44
+ """A sign-in has lapsed, and only a person signing in again can renew it.
45
+
46
+ Retrying does not help: the refresh token behind the session has expired or been
47
+ revoked, or a policy wants a fresh sign-in. ``reason`` says which, in plain words.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ hint: str | None = None,
55
+ tenant_id: str | None = None,
56
+ reason: str | None = None,
57
+ ) -> None:
58
+ super().__init__(message, hint=hint)
59
+ self.tenant_id = tenant_id
60
+ self.reason = reason
61
+
62
+
63
+ class TokenError(LdoError):
64
+ """A token could not be decoded."""
65
+
66
+
67
+ class NotFoundError(LdoError):
68
+ """A named object does not exist."""
69
+
70
+
71
+ class AmbiguousError(LdoError):
72
+ """A name matched more than one object where exactly one was needed."""
73
+
74
+
75
+ class ApiError(LdoError):
76
+ """An HTTP API call failed."""
77
+
78
+ def __init__(
79
+ self,
80
+ message: str,
81
+ *,
82
+ status: int | None = None,
83
+ code: str | None = None,
84
+ request_id: str | None = None,
85
+ hint: str | None = None,
86
+ ) -> None:
87
+ super().__init__(message, hint=hint)
88
+ self.status = status
89
+ self.code = code
90
+ self.request_id = request_id