midpoint-cli 1.6.0__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 (47) hide show
  1. midpoint_cli/__init__.py +234 -0
  2. midpoint_cli/client/__init__.py +199 -0
  3. midpoint_cli/client/audit.py +178 -0
  4. midpoint_cli/client/audit_query.py +247 -0
  5. midpoint_cli/client/configuration.py +15 -0
  6. midpoint_cli/client/constants.py +37 -0
  7. midpoint_cli/client/errors.py +74 -0
  8. midpoint_cli/client/logging_config.py +122 -0
  9. midpoint_cli/client/midpoint_client.py +866 -0
  10. midpoint_cli/client/object_query.py +230 -0
  11. midpoint_cli/client/objects.py +814 -0
  12. midpoint_cli/client/observer.py +29 -0
  13. midpoint_cli/client/patch.py +113 -0
  14. midpoint_cli/client/progress.py +80 -0
  15. midpoint_cli/client/rest.py +490 -0
  16. midpoint_cli/client/safe_xml.py +24 -0
  17. midpoint_cli/client/server_info.py +130 -0
  18. midpoint_cli/client/server_version.py +74 -0
  19. midpoint_cli/client/session.py +142 -0
  20. midpoint_cli/client/task_definitions.py +110 -0
  21. midpoint_cli/prompt/__init__.py +196 -0
  22. midpoint_cli/prompt/audit.py +253 -0
  23. midpoint_cli/prompt/base.py +218 -0
  24. midpoint_cli/prompt/complete.py +291 -0
  25. midpoint_cli/prompt/configuration.py +258 -0
  26. midpoint_cli/prompt/connector.py +45 -0
  27. midpoint_cli/prompt/console.py +818 -0
  28. midpoint_cli/prompt/delete.py +89 -0
  29. midpoint_cli/prompt/get.py +66 -0
  30. midpoint_cli/prompt/info.py +53 -0
  31. midpoint_cli/prompt/log.py +98 -0
  32. midpoint_cli/prompt/org.py +122 -0
  33. midpoint_cli/prompt/output.py +33 -0
  34. midpoint_cli/prompt/put.py +71 -0
  35. midpoint_cli/prompt/report.py +69 -0
  36. midpoint_cli/prompt/resource.py +217 -0
  37. midpoint_cli/prompt/role.py +96 -0
  38. midpoint_cli/prompt/script.py +154 -0
  39. midpoint_cli/prompt/search.py +51 -0
  40. midpoint_cli/prompt/shadow.py +87 -0
  41. midpoint_cli/prompt/task.py +296 -0
  42. midpoint_cli/prompt/user.py +406 -0
  43. midpoint_cli-1.6.0.dist-info/METADATA +1334 -0
  44. midpoint_cli-1.6.0.dist-info/RECORD +47 -0
  45. midpoint_cli-1.6.0.dist-info/WHEEL +4 -0
  46. midpoint_cli-1.6.0.dist-info/entry_points.txt +3 -0
  47. midpoint_cli-1.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,490 @@
1
+ """REST transport: authenticated calls to the Midpoint REST API and their error handling."""
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from typing import Any, NamedTuple
6
+ from urllib.parse import urljoin, urlparse
7
+ from xml.etree import ElementTree
8
+ from xml.etree.ElementTree import Element
9
+
10
+ import requests
11
+ from urllib3.exceptions import MaxRetryError
12
+
13
+ from midpoint_cli.client.configuration import MidpointClientConfiguration
14
+ from midpoint_cli.client.errors import (
15
+ MidpointInvalidOid,
16
+ MidpointMissingPassword,
17
+ MidpointObjectNotFound,
18
+ MidpointServerError,
19
+ MidpointUnsupportedOperation,
20
+ )
21
+ from midpoint_cli.client.objects import MidpointObjectType, MidpointObjectTypes
22
+ from midpoint_cli.client.observer import MidpointCommunicationObserver, NopMidpointCommunicationObserver
23
+ from midpoint_cli.client.patch import patch_from_file
24
+ from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
25
+ from midpoint_cli.client.session import (
26
+ READ_TIMEOUT_ENV_VAR,
27
+ RETRY_TIMEOUT_ENV_VAR,
28
+ CustomHTTPAdapter,
29
+ CustomRetryManager,
30
+ read_timeout_seconds,
31
+ retry_attempts_for,
32
+ retry_timeout_seconds,
33
+ )
34
+
35
+ # Connect timeout in seconds for every REST call; the read timeout is configurable
36
+ # (MidpointClientConfiguration.read_timeout). Without them a server that accepts the
37
+ # connection but never answers hangs the CLI forever. A connect timeout is retried like any
38
+ # other connection failure, a read timeout is not (see session.py).
39
+ CONNECT_TIMEOUT_SECONDS = 10
40
+
41
+ # Header in which Midpoint 4.10+ answers a search sent with returnTotalCount=true.
42
+ TOTAL_COUNT_HEADER = 'X-Total-Count'
43
+
44
+
45
+ # Midpoint's native repository only accepts UUIDs, but the generic repository (H2 and
46
+ # older setups) stores any string up to 36 characters, so OIDs such as 'my-role-01' exist.
47
+ # Accept a conservative token: no '/', '?', '#', '%', whitespace, and no leading '.', so
48
+ # urljoin cannot resolve '..' segments into another collection or out of the context path.
49
+ _SAFE_PATH_SEGMENT = re.compile(r'[A-Za-z0-9][A-Za-z0-9._:-]*')
50
+
51
+
52
+ def _check_path_segment(value: str, what: str = 'OID') -> str:
53
+ """Return value unchanged if it is safe as a single REST URL path segment, else raise MidpointInvalidOid."""
54
+ if not isinstance(value, str) or _SAFE_PATH_SEGMENT.fullmatch(value) is None:
55
+ raise MidpointInvalidOid(
56
+ f'Invalid {what} {value!r}: only letters, digits and ".", "_", ":", "-" are allowed, '
57
+ f'starting with a letter or digit'
58
+ )
59
+ return value
60
+
61
+
62
+ def _search_options(options: Sequence[str]) -> dict[str, Any]:
63
+ """The ``options`` query parameters of a search: GetOperationOptions in their REST form (e.g. 'raw')."""
64
+ return {'options': [_check_path_segment(o, 'option') for o in options]} if options else {}
65
+
66
+
67
+ class PutResult(NamedTuple):
68
+ """Outcome of an upload: the object type, its OID (None if the server did not report one) and
69
+ whether it was created by POST (True) or written to a known OID by PUT (False)."""
70
+
71
+ object_type: MidpointObjectType
72
+ oid: str | None
73
+ created: bool
74
+
75
+
76
+ _ERROR_DETAIL_MAX_LEN = 500
77
+
78
+
79
+ def _extract_error_detail(content: bytes) -> str | None:
80
+ """Pull the human-readable reason out of a Midpoint error body.
81
+
82
+ Midpoint answers a failed REST call with an OperationResultType document whose
83
+ <message> element carries the actual reason ("Object of type 'UserType' with oid
84
+ '...' was not found."). Without it the CLI could only report a bare status code.
85
+ Bodies that aren't XML (proxy error pages, plain text) are reported verbatim,
86
+ truncated.
87
+ """
88
+ if not content:
89
+ return None
90
+
91
+ try:
92
+ tree = safe_fromstring(content)
93
+ except ElementTree.ParseError:
94
+ text = ' '.join(content.decode('utf-8', errors='replace').split())
95
+ return text[:_ERROR_DETAIL_MAX_LEN] or None
96
+
97
+ # Document order, so a top-level <message> wins over a nested <partialResults> one.
98
+ for node in tree.iter():
99
+ if node.tag.rsplit('}', 1)[-1] == 'message' and node.text and node.text.strip():
100
+ return ' '.join(node.text.split())[:_ERROR_DETAIL_MAX_LEN]
101
+
102
+ return None
103
+
104
+
105
+ def _describe_request_failure(
106
+ url: str,
107
+ error: requests.RequestException,
108
+ retry_timeout: float | None = None,
109
+ read_timeout: float | None = None,
110
+ ) -> str:
111
+ """Turn a transport-level requests failure into a one-line, actionable message."""
112
+ # requests wraps the urllib3 MaxRetryError whose reason is the actual cause; its own
113
+ # str() repeats the pool and URL details, so report the innermost reason instead.
114
+ reason: object = error
115
+ retried = bool(error.args) and isinstance(error.args[0], MaxRetryError)
116
+ if retried and error.args[0].reason is not None:
117
+ reason = error.args[0].reason
118
+
119
+ if isinstance(error, requests.exceptions.SSLError):
120
+ return (
121
+ f'TLS/SSL error on the REST call to {url}: {reason} — '
122
+ f'check the server certificate and that the URL scheme (http/https) is correct'
123
+ )
124
+ # A MaxRetryError means the retry budget of session.py was spent before giving up.
125
+ gave_up = (
126
+ f' (gave up after retrying for up to {retry_timeout:g} s; use --timeout or {RETRY_TIMEOUT_ENV_VAR} to wait longer)'
127
+ if retried and retry_timeout is not None
128
+ else ''
129
+ )
130
+ if isinstance(error, requests.exceptions.ConnectTimeout):
131
+ return f'Timed out connecting to the server for the REST call to {url}: {reason}{gave_up}'
132
+ if isinstance(error, requests.exceptions.Timeout):
133
+ within = f' within {read_timeout:g} seconds' if read_timeout is not None else ''
134
+ return (
135
+ f'Server did not answer the REST call to {url}{within} '
136
+ f'(use --read-timeout or {READ_TIMEOUT_ENV_VAR} to wait longer)'
137
+ )
138
+ if isinstance(error, requests.exceptions.ConnectionError):
139
+ return (
140
+ f'Cannot connect to the server for the REST call to {url}: {reason}{gave_up} '
141
+ f'— check the configured server URL'
142
+ )
143
+ return f'REST call to {url} failed: {reason}'
144
+
145
+
146
+ class RestApiClient:
147
+ """Low-level client of the Midpoint REST API (``ws/rest/...``), one method per HTTP call.
148
+
149
+ The verbs return what the call needs, so their return types differ: a decoded ``str``
150
+ (get_element, delete, patch_element, get_endpoint, post_xml), raw ``bytes``
151
+ (execute_action), a parsed ``Element`` (get_elements, search_elements), an OID
152
+ (create_element) or a PutResult (put_element).
153
+
154
+ Every verb raises the same errors, all subclasses of MidpointServerError:
155
+
156
+ - MidpointInvalidOid before anything is sent, when an OID, endpoint or action is not a
157
+ safe URL path segment;
158
+ - MidpointMissingPassword before anything is sent, when the configuration has no password;
159
+ - MidpointObjectNotFound when the server answers 404;
160
+ - MidpointServerError for any other error status (with the server's message when it
161
+ gives one), a redirect or HTML page (a base URL without the Midpoint context path),
162
+ and transport failures (refused connection, timeout, TLS error, retries exhausted).
163
+
164
+ Methods that parse the answer also raise ``xml.etree.ElementTree.ParseError`` when it is
165
+ not XML or holds constructs refused by safe_xml.
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ client_configuration: MidpointClientConfiguration,
171
+ observer: MidpointCommunicationObserver | None = None,
172
+ ):
173
+ self.configuration = client_configuration
174
+
175
+ if observer is None:
176
+ # No-op observer implementation
177
+ observer = NopMidpointCommunicationObserver()
178
+
179
+ session = requests.Session()
180
+
181
+ # Overall wall-clock budget for retrying a request that gets no answer (session.py).
182
+ timeout = client_configuration.timeout
183
+ self.retry_timeout_seconds = timeout if timeout is not None else retry_timeout_seconds()
184
+ attempts = retry_attempts_for(self.retry_timeout_seconds)
185
+ read_timeout = client_configuration.read_timeout
186
+ self.read_timeout_seconds = read_timeout if read_timeout is not None else read_timeout_seconds()
187
+ adapter = CustomHTTPAdapter(
188
+ max_retries=CustomRetryManager(
189
+ connect=attempts, total=attempts, timeout_seconds=self.retry_timeout_seconds, observer=observer
190
+ ),
191
+ observer=observer,
192
+ )
193
+ session.mount('http://', adapter)
194
+ session.mount('https://', adapter)
195
+
196
+ self.requests_session = session
197
+
198
+ def _url(self, path: str) -> str:
199
+ """Resolve a server-relative path against the configured base URL.
200
+
201
+ ``urljoin`` replaces the last path segment of a base without a trailing slash, so
202
+ 'http://host/midpoint' + 'ws/rest/users' would lose the context path and target
203
+ 'http://host/ws/rest/users'. The base is read at call time because the configuration
204
+ is filled in field by field after the client is built.
205
+ """
206
+ base = self.configuration.url
207
+ if not base.endswith('/'):
208
+ base += '/'
209
+ return urljoin(base, path)
210
+
211
+ def _collection_url(self, element_class: MidpointObjectType, *segments: str) -> str:
212
+ """Build the REST URL of a collection (``ws/rest/<endpoint>``), followed by already checked segments."""
213
+ if element_class.endpoint is None:
214
+ raise AssertionError(f'Element class {element_class} has no endpoint')
215
+ path = [_check_path_segment(element_class.endpoint, 'endpoint'), *segments]
216
+ return self._url('ws/rest/' + '/'.join(path))
217
+
218
+ def _object_url(self, element_class: MidpointObjectType, element_oid: str, action: str | None = None) -> str:
219
+ """Build the REST URL of one object (optionally of an action on it), validating every segment."""
220
+ if element_class.endpoint is None:
221
+ raise AssertionError(f'Element class {element_class} has no endpoint')
222
+ endpoint = _check_path_segment(element_class.endpoint, 'endpoint')
223
+ segments = [_check_path_segment(element_oid)]
224
+ if action is not None:
225
+ segments.append(_check_path_segment(action, 'action'))
226
+ return self._url('ws/rest/' + '/'.join([endpoint, *segments]))
227
+
228
+ def _rest_request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
229
+ """Send an authenticated REST request, refusing redirects and HTML answers.
230
+
231
+ Midpoint REST endpoints answer directly with XML: a redirect or an HTML body
232
+ means the call landed on the web UI instead of the REST API — typically a base
233
+ URL missing the /midpoint/ context path (recent Midpoint container images serve
234
+ strictly under /midpoint, and the root URL answers with redirects ending at the
235
+ HTML login page). Silently following that redirect used to surface as a cryptic
236
+ ``xml.etree.ElementTree.ParseError: mismatched tag`` when the login page was
237
+ parsed as XML.
238
+ """
239
+ if not self.configuration.password:
240
+ raise MidpointMissingPassword(
241
+ f'No password configured for user {self.configuration.username}: '
242
+ f'refusing to send an unauthenticated REST call to {url}'
243
+ )
244
+ try:
245
+ response = self.requests_session.request(
246
+ method,
247
+ url,
248
+ auth=(self.configuration.username, self.configuration.password),
249
+ allow_redirects=False,
250
+ timeout=(CONNECT_TIMEOUT_SECONDS, self.read_timeout_seconds),
251
+ **kwargs,
252
+ )
253
+ except requests.RequestException as e:
254
+ # Transport failures (refused connection, timeout, TLS error, exhausted retries)
255
+ # are reported like server errors so command handlers print a clean message
256
+ # instead of a traceback.
257
+ raise MidpointServerError(
258
+ _describe_request_failure(url, e, self.retry_timeout_seconds, self.read_timeout_seconds)
259
+ ) from e
260
+
261
+ if 300 <= response.status_code < 400:
262
+ location = response.headers.get('Location', '(no Location header)')
263
+ raise MidpointServerError(
264
+ f'Server redirected the REST call on {url} to {location}. '
265
+ f'REST endpoints never redirect — check the configured server URL: '
266
+ f"it must include the Midpoint context path, e.g. 'http://localhost:8080/midpoint/'."
267
+ )
268
+
269
+ if 'text/html' in response.headers.get('Content-Type', ''):
270
+ raise MidpointServerError(
271
+ f'Server answered the REST call on {url} with an HTML page '
272
+ f'(HTTP {response.status_code}) instead of a REST response — '
273
+ f'check the configured server URL: '
274
+ f"it must include the Midpoint context path, e.g. 'http://localhost:8080/midpoint/'."
275
+ )
276
+
277
+ # Checked here rather than in each verb so no REST call can silently succeed on an
278
+ # error status. Reaching this point means the status is < 300 or >= 400: the redirect
279
+ # branch above already rejected 301-399.
280
+ if response.status_code >= 400:
281
+ message = f'Server responded with status code {response.status_code} on {url}'
282
+ if response.status_code in (401, 403):
283
+ message += ' — check the configured username and password'
284
+ detail = _extract_error_detail(response.content)
285
+ if detail:
286
+ message += f': {detail}'
287
+ error = MidpointObjectNotFound(message) if response.status_code == 404 else MidpointServerError(message)
288
+ error.status_code = response.status_code
289
+ raise error
290
+
291
+ return response
292
+
293
+ def get_element(self, element_class: MidpointObjectType, element_oid: str) -> str:
294
+ """GET one object (``ws/rest/<collection>/<oid>``) and return its XML, decoded to ``str``.
295
+
296
+ element_class gives the collection and element_oid the object. Errors as described in
297
+ the class docstring; a missing object raises MidpointObjectNotFound.
298
+ """
299
+ response = self._rest_request('get', self._object_url(element_class, element_oid))
300
+
301
+ return response.content.decode()
302
+
303
+ def delete(self, element_class: MidpointObjectType, element_oid: str) -> str:
304
+ """DELETE one object (``ws/rest/<collection>/<oid>``) and return the answer body, decoded to ``str``.
305
+
306
+ Midpoint answers a successful delete with no content, so the result is usually empty.
307
+ Errors as described in the class docstring; a missing object raises MidpointObjectNotFound.
308
+ """
309
+ response = self._rest_request('delete', self._object_url(element_class, element_oid))
310
+
311
+ return response.content.decode()
312
+
313
+ def patch_element(self, element_class: MidpointObjectType, element_oid: str, xml_body: str) -> str:
314
+ """PATCH one object with xml_body, an ``ObjectModificationType`` document of item deltas.
315
+
316
+ Returns the answer body decoded to ``str``, usually empty. Errors as described in the
317
+ class docstring; a server that refuses the modification answers an error status.
318
+ """
319
+ target_url = self._object_url(element_class, element_oid)
320
+ response = self._rest_request(
321
+ 'patch',
322
+ target_url,
323
+ data=xml_body,
324
+ headers={'Content-Type': 'application/xml'},
325
+ )
326
+ return response.content.decode()
327
+
328
+ def get_elements(self, element_class: MidpointObjectType) -> Element:
329
+ """GET a whole collection (``ws/rest/<collection>``) and return the parsed ``<objects>`` ``Element``.
330
+
331
+ Every object of the type is downloaded: use search_elements to filter or page on the
332
+ server. Errors as described in the class docstring, including ParseError.
333
+ """
334
+ url = self._collection_url(element_class)
335
+ response = self._rest_request('get', url)
336
+ tree = safe_fromstring(response.content)
337
+ return tree
338
+
339
+ def search_elements(
340
+ self, element_class: MidpointObjectType, query_xml: str, options: Sequence[str] = ()
341
+ ) -> Element:
342
+ """POST a QueryType to the search endpoint of a collection and return the parsed object list.
343
+
344
+ options are GetOperationOptions in their REST form (e.g. ``raw``), sent as ``options``
345
+ query parameters, which the search endpoint of 4.4 to 4.11 reads.
346
+ """
347
+ response = self._search(element_class, query_xml, _search_options(options))
348
+ return safe_fromstring(response.content)
349
+
350
+ def count_elements(
351
+ self, element_class: MidpointObjectType, query_xml: str, options: Sequence[str] = ()
352
+ ) -> int | None:
353
+ """The number of objects matching a QueryType, ignoring its paging, or None if the server cannot tell.
354
+
355
+ Midpoint 4.10 added ``returnTotalCount`` to the search endpoint: the answer then carries
356
+ the count in an ``X-Total-Count`` header. Earlier versions ignore the parameter and send
357
+ no header. Give the query a small maxSize, as the matching objects are still returned.
358
+ options are sent as in search_elements.
359
+ """
360
+ params: dict[str, Any] = {'returnTotalCount': 'true', **_search_options(options)}
361
+ response = self._search(element_class, query_xml, params)
362
+ header = response.headers.get(TOTAL_COUNT_HEADER)
363
+ try:
364
+ return int(header) if header is not None else None
365
+ except ValueError:
366
+ return None
367
+
368
+ def _search(
369
+ self, element_class: MidpointObjectType, query_xml: str, params: dict[str, Any] | None
370
+ ) -> requests.Response:
371
+ url = self._collection_url(element_class, 'search')
372
+ kwargs: dict[str, Any] = {'params': params} if params else {}
373
+ return self._rest_request(
374
+ 'post',
375
+ url,
376
+ data=query_xml.encode('utf-8'),
377
+ headers={'Content-Type': 'application/xml'},
378
+ **kwargs,
379
+ )
380
+
381
+ def create_element(self, element_class: MidpointObjectType, xml_body: str) -> str | None:
382
+ """POST a new object to its collection; return its OID from the Location header (None if not reported)."""
383
+ url = self._collection_url(element_class)
384
+ response = self._rest_request(
385
+ 'post',
386
+ url,
387
+ data=xml_body.encode('utf-8'),
388
+ headers={'Content-Type': 'application/xml'},
389
+ )
390
+ return self._created_oid(response)
391
+
392
+ def execute_action(self, element_class: MidpointObjectType, element_oid: str, action: str) -> bytes:
393
+ """POST an action on one object (``ws/rest/<collection>/<oid>/<action>``) and return the raw answer.
394
+
395
+ Actions are e.g. ``run``, ``suspend`` and ``resume`` on tasks, or ``test`` on resources,
396
+ whose answer is an ``OperationResultType`` document. The body is returned as undecoded
397
+ ``bytes``, to be parsed by the caller. Errors as described in the class docstring.
398
+ """
399
+ response = self._rest_request('post', self._object_url(element_class, element_oid, action))
400
+
401
+ return response.content
402
+
403
+ def get_endpoint(self, path: str) -> str:
404
+ """GET any path under the configured base URL (e.g. ``actuator/info``, ``ws/rest/self``).
405
+
406
+ Returns the answer body decoded to ``str``, whatever its format (XML or JSON). path is
407
+ relative to the base URL and is not validated: callers pass fixed paths only. Errors as
408
+ described in the class docstring.
409
+ """
410
+ url = self._url(path)
411
+ response = self._rest_request('get', url)
412
+ return response.content.decode()
413
+
414
+ def post_xml(self, path: str, xml_body: str) -> str:
415
+ """POST an XML body to an arbitrary path under the configured base URL."""
416
+ url = self._url(path)
417
+ response = self._rest_request(
418
+ 'post',
419
+ url,
420
+ data=xml_body,
421
+ headers={'Content-Type': 'application/xml'},
422
+ )
423
+ return response.content.decode()
424
+
425
+ def put_element(self, xml_filename: str, patch_file: str | None, patch_write: bool) -> PutResult:
426
+ """Upload an object from an XML file, creating or replacing it.
427
+
428
+ A root element with an ``oid`` attribute is PUT to the object URL, which creates or
429
+ overwrites the object at that OID. Without one, the document is POSTed to the type
430
+ collection and the server picks the OID, read back from the ``Location`` header.
431
+ The patch, if any, is applied first, so it may add, change or drop the OID.
432
+
433
+ xml_filename is the local file to upload. patch_file, if not None, is an XML patch file
434
+ applied to it first; with patch_write the patched document is also written next to the
435
+ patch file (``<patch_file>.xml``). Returns a PutResult with the object type, its OID
436
+ (None when the server created the object without reporting one) and whether it was
437
+ created by POST.
438
+
439
+ Raises OSError when a file cannot be read, ParseError when the document is not XML,
440
+ MidpointTypeNotFound when its root element is not a known object type,
441
+ MidpointUnsupportedOperation when the type has no REST collection, and the errors
442
+ described in the class docstring.
443
+ """
444
+ with open(xml_filename) as xml_file:
445
+ xml_body = xml_file.read()
446
+
447
+ if patch_file is not None:
448
+ xml_body = patch_from_file(xml_body, patch_file, patch_write)
449
+
450
+ # A local file, not server XML: parsed with the standard parser, as before, so the user's own
451
+ # files keep working whatever they declare (S314 targets untrusted input).
452
+ tree_root = ElementTree.fromstring(xml_body) # noqa: S314
453
+
454
+ root_tag_name = tree_root.tag.split('}', 1)[1] if '}' in tree_root.tag else tree_root.tag # strip namespace
455
+ object_type = MidpointObjectTypes.find_by_name(root_tag_name)
456
+
457
+ if not object_type.endpoint:
458
+ raise MidpointUnsupportedOperation(f'Upload of {object_type} is not supported through REST API')
459
+
460
+ object_oid = tree_root.attrib.get('oid')
461
+ headers = {'Content-Type': 'application/xml'}
462
+
463
+ if object_oid is None:
464
+ collection_url = self._collection_url(object_type)
465
+ response = self._rest_request('post', collection_url, data=xml_body, headers=headers)
466
+ return PutResult(object_type, self._created_oid(response), created=True)
467
+
468
+ # The OID comes from the uploaded file, so it is validated before anything is sent.
469
+ target_url = self._object_url(object_type, object_oid)
470
+ self._rest_request('put', target_url, data=xml_body, headers=headers)
471
+ return PutResult(object_type, object_oid, created=False)
472
+
473
+ @staticmethod
474
+ def _created_oid(response: requests.Response) -> str | None:
475
+ """Read the OID of a newly created object from the Location header of a POST answer.
476
+
477
+ Midpoint answers 201 Created (202 Accepted for tasks) with a Location ending in the
478
+ new OID. An answer without Location means the object was accepted but not created
479
+ yet, e.g. while it waits for an approval, so it has no OID to report.
480
+ """
481
+ location = response.headers.get('Location')
482
+ if not location:
483
+ return None
484
+ oid = urlparse(location).path.rstrip('/').rsplit('/', 1)[-1]
485
+ try:
486
+ return _check_path_segment(oid)
487
+ except MidpointInvalidOid as e:
488
+ raise MidpointServerError(
489
+ f'The object was created, but the Location header {location!r} does not end with a valid OID'
490
+ ) from e
@@ -0,0 +1,24 @@
1
+ """Parsing of XML received from the server, hardened with defusedxml.
2
+
3
+ The stdlib parser expands internal entities, so a hostile or intercepted response
4
+ (plain http://) could exhaust memory with a "billion laughs" document. defusedxml
5
+ refuses entity declarations and external references instead.
6
+ """
7
+
8
+ from xml.etree.ElementTree import Element, ParseError
9
+
10
+ from defusedxml import DefusedXmlException
11
+ from defusedxml.ElementTree import fromstring as _defused_fromstring
12
+
13
+
14
+ def fromstring(text: str | bytes) -> Element:
15
+ """Parse server-supplied XML into an Element.
16
+
17
+ A refused construct is raised as ``ParseError`` so callers keep a single
18
+ error path for any response that cannot be used as XML.
19
+ """
20
+ try:
21
+ root: Element = _defused_fromstring(text)
22
+ except DefusedXmlException as e:
23
+ raise ParseError(f'Refused unsafe XML from the server: {e}') from e
24
+ return root
@@ -0,0 +1,130 @@
1
+ """What `info` shows about the server, and how it is read from each REST source.
2
+
3
+ Midpoint has no single REST call for the "About" page of the web UI, so the information is
4
+ gathered from calls that every supported release (4.4 to 4.11) answers the same way:
5
+
6
+ * ``actuator/info``: version, build, branch, build timestamp and license (see server_version.py);
7
+ * ``ws/rest/nodes``: the cluster nodes, each with its host, state, Midpoint build and last
8
+ check-in (NodeType, unchanged from 4.4.11 to 4.11.0);
9
+ * ``ws/rest/systemConfigurations/<oid>``: the deployment name and whether a subscription
10
+ identifier is configured (SystemConfigurationType/deploymentInformation);
11
+ * ``ws/rest/self``: the account the CLI is logged in as.
12
+
13
+ The repository implementation is not exposed over REST. Midpoint 4.10 dropped the generic
14
+ (SQL) repository, so from 4.10 on the repository is known to be the native PostgreSQL one;
15
+ for earlier versions it is reported as unknown.
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+ from xml.etree.ElementTree import Element
20
+
21
+ from midpoint_cli.client.objects import namespaces
22
+ from midpoint_cli.client.server_version import MidpointVersion
23
+
24
+ # OID of the single SystemConfigurationType object, the same in every Midpoint release.
25
+ SYSTEM_CONFIGURATION_OID = '00000000-0000-0000-0000-000000000001'
26
+
27
+ # The 4.10.5 and 4.11.0 distributions no longer ship repo-sql-impl, the generic repository.
28
+ NATIVE_REPOSITORY_ONLY_SINCE = (4, 10)
29
+
30
+ # actuator/info fields and the label `info` shows them under, in display order.
31
+ ACTUATOR_FIELDS = (
32
+ ('version', 'Midpoint Version'),
33
+ ('build', 'Build'),
34
+ ('branch', 'Branch'),
35
+ ('buildTimestamp', 'Build Timestamp'),
36
+ ('license', 'License'),
37
+ )
38
+
39
+
40
+ def _text(parent: Element | None, path: str) -> str | None:
41
+ """Stripped text of the element at path under parent, or None when missing or blank."""
42
+ node = parent.find(path, namespaces) if parent is not None else None
43
+ text = node.text.strip() if node is not None and node.text is not None else ''
44
+ return text or None
45
+
46
+
47
+ @dataclass
48
+ class ServerInformation:
49
+ """Server properties in display order, and the sources that could not be read, with the reason."""
50
+
51
+ properties: dict[str, str] = field(default_factory=dict)
52
+ unavailable: list[str] = field(default_factory=list)
53
+
54
+
55
+ @dataclass
56
+ class NodeInformation:
57
+ identifier: str
58
+ hostname: str | None = None
59
+ state: str | None = None
60
+ version: str | None = None
61
+ last_check_in: str | None = None
62
+
63
+ def describe(self) -> str:
64
+ """One line for the node, e.g. 'mp1.example.com, running, Midpoint 4.8.14, last check-in 2026-...'."""
65
+ parts = [
66
+ self.hostname,
67
+ self.state,
68
+ f'Midpoint {self.version}' if self.version else None,
69
+ f'last check-in {self.last_check_in}' if self.last_check_in else None,
70
+ ]
71
+ return ', '.join(part for part in parts if part) or '(no details)'
72
+
73
+
74
+ def parse_nodes(objects: Element) -> list[NodeInformation]:
75
+ """The nodes of a ws/rest/nodes object list.
76
+
77
+ The execution state (running, paused, error, ...) is more telling than the operational
78
+ state (up, down, starting), so it is shown when the node has one.
79
+ """
80
+ nodes = []
81
+ for node in objects:
82
+ identifier = _text(node, 'c:nodeIdentifier') or _text(node, 'c:name')
83
+ if identifier is None:
84
+ continue
85
+ nodes.append(
86
+ NodeInformation(
87
+ identifier=identifier,
88
+ hostname=_text(node, 'c:hostname'),
89
+ state=_text(node, 'c:executionState') or _text(node, 'c:operationalState'),
90
+ version=_text(node, 'c:build/c:version'),
91
+ last_check_in=_text(node, 'c:lastCheckInTime'),
92
+ )
93
+ )
94
+ return nodes
95
+
96
+
97
+ def parse_logged_in_user(user: Element) -> str | None:
98
+ """'name (full name)' of the ws/rest/self answer, or just the name."""
99
+ name = _text(user, 'c:name')
100
+ if name is None:
101
+ return None
102
+ full_name = _text(user, 'c:fullName')
103
+ return f'{name} ({full_name})' if full_name and full_name != name else name
104
+
105
+
106
+ def parse_deployment(system_configuration: Element) -> dict[str, str]:
107
+ """Deployment name and subscription status from the system configuration.
108
+
109
+ Only the presence of a subscription identifier is reported: its validity (format, check
110
+ digit, expiry) is decided by the server, with rules that changed across releases.
111
+ """
112
+ deployment = system_configuration.find('c:deploymentInformation', namespaces)
113
+ properties: dict[str, str] = {}
114
+ name = _text(deployment, 'c:name')
115
+ if name:
116
+ properties['Deployment Name'] = name
117
+ subscription = _text(deployment, 'c:subscriptionIdentifier')
118
+ properties['Subscription'] = (
119
+ 'identifier configured (validity is checked by the server)' if subscription else 'none configured'
120
+ )
121
+ return properties
122
+
123
+
124
+ def describe_repository(version: MidpointVersion | None) -> str | None:
125
+ """The repository implementation as far as the version tells, or None when the version is unknown."""
126
+ if version is None:
127
+ return None
128
+ if version.at_least(*NATIVE_REPOSITORY_ONLY_SINCE):
129
+ return 'native (PostgreSQL)'
130
+ return 'not reported over REST before Midpoint 4.10 (see the About page of the web UI)'