midpoint-cli 1.6.0.dev3__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.dev3.dist-info/METADATA +1334 -0
  44. midpoint_cli-1.6.0.dev3.dist-info/RECORD +47 -0
  45. midpoint_cli-1.6.0.dev3.dist-info/WHEEL +4 -0
  46. midpoint_cli-1.6.0.dev3.dist-info/entry_points.txt +3 -0
  47. midpoint_cli-1.6.0.dev3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,866 @@
1
+ """MidpointClient: the high-level operations of the CLI on a Midpoint server."""
2
+
3
+ import contextlib
4
+ import json
5
+ import logging
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable, Sequence
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+ from xml.etree import ElementTree
12
+ from xml.etree.ElementTree import Element
13
+ from xml.sax.saxutils import escape as xml_escape
14
+ from xml.sax.saxutils import quoteattr
15
+
16
+ import requests
17
+
18
+ from midpoint_cli.client.audit import (
19
+ as_utc,
20
+ audit_script_error,
21
+ initial_audit_cursor,
22
+ parse_audit_events_from_script_response,
23
+ parse_audit_timestamp,
24
+ )
25
+ from midpoint_cli.client.audit_query import AUDIT_EXEC_SCRIPT_PATH, AuditQuery, build_execute_script_body
26
+ from midpoint_cli.client.configuration import MidpointClientConfiguration
27
+ from midpoint_cli.client.constants import API_TYPES_NS, COMMON_NS, TYPES_NS, ResultStatus, TaskExecutionState
28
+ from midpoint_cli.client.errors import (
29
+ MidpointAmbiguousName,
30
+ MidpointClientNotConfigured,
31
+ MidpointMissingPassword,
32
+ MidpointObjectNotFound,
33
+ MidpointServerError,
34
+ TaskExecutionFailure,
35
+ )
36
+ from midpoint_cli.client.logging_config import (
37
+ LoggingConfiguration,
38
+ check_logger_name,
39
+ class_logger_modification,
40
+ normalize_level,
41
+ parse_logging,
42
+ )
43
+ from midpoint_cli.client.object_query import (
44
+ ORG_SEARCH_PATHS,
45
+ ROLE_SEARCH_PATHS,
46
+ USER_SEARCH_PATHS,
47
+ ObjectQuery,
48
+ and_filter,
49
+ equal_filter,
50
+ in_oid_filter,
51
+ is_oid,
52
+ ref_filter,
53
+ task_status_filter,
54
+ )
55
+ from midpoint_cli.client.objects import (
56
+ MidpointConnector,
57
+ MidpointObject,
58
+ MidpointObjectList,
59
+ MidpointObjectType,
60
+ MidpointObjectTypes,
61
+ MidpointOrganization,
62
+ MidpointResource,
63
+ MidpointRole,
64
+ MidpointShadow,
65
+ MidpointTask,
66
+ MidpointUser,
67
+ namespaces,
68
+ safe_get_text,
69
+ )
70
+ from midpoint_cli.client.observer import MidpointCommunicationObserver
71
+ from midpoint_cli.client.progress import ProgressMonitor
72
+ from midpoint_cli.client.rest import PutResult, RestApiClient
73
+ from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
74
+ from midpoint_cli.client.server_info import (
75
+ ACTUATOR_FIELDS,
76
+ SYSTEM_CONFIGURATION_OID,
77
+ ServerInformation,
78
+ describe_repository,
79
+ parse_deployment,
80
+ parse_logged_in_user,
81
+ parse_nodes,
82
+ )
83
+ from midpoint_cli.client.server_version import MidpointVersion, parse_server_version
84
+ from midpoint_cli.client.task_definitions import (
85
+ report_task_name,
86
+ report_task_xml,
87
+ resource_task_name,
88
+ resource_task_xml,
89
+ )
90
+
91
+ logger = logging.getLogger(__name__)
92
+
93
+ # SynchronizationSituationType values, the same from 4.4.11 to 4.11.0.
94
+ SHADOW_SITUATIONS = ('linked', 'unlinked', 'unmatched', 'disputed', 'deleted')
95
+ # Shadow counts are read from the repository only: no resource is contacted and no
96
+ # definitions are applied, so a resource that is down or has no schema can still be counted.
97
+ _SHADOW_COUNT_OPTIONS = ('raw',)
98
+
99
+
100
+ # Local classes of the object types the CLI displays; other types (archetypes, services, cases,
101
+ # lookup tables, nodes, object collections, dashboards, sequences, ...) are parsed to OID and name only.
102
+ # Tasks are parsed by _parse_object itself, as they need the server version.
103
+ _LOCAL_CLASSES: tuple[tuple[MidpointObjectTypes, type[MidpointObject]], ...] = (
104
+ (MidpointObjectTypes.USER, MidpointUser),
105
+ (MidpointObjectTypes.ROLE, MidpointRole),
106
+ (MidpointObjectTypes.ORG, MidpointOrganization),
107
+ (MidpointObjectTypes.RESOURCE, MidpointResource),
108
+ (MidpointObjectTypes.CONNECTOR, MidpointConnector),
109
+ (MidpointObjectTypes.SHADOW, MidpointShadow),
110
+ )
111
+
112
+
113
+ def _parse_object(
114
+ object_type: MidpointObjectType, entity: Element, server_version: MidpointVersion | None = None
115
+ ) -> MidpointObject:
116
+ """Parse an object element with the local class of object_type, or to OID and name for other types.
117
+
118
+ Tasks are parsed for server_version, whose task schema they follow.
119
+ """
120
+ if object_type == MidpointObjectTypes.TASK.value:
121
+ return MidpointTask(entity, server_version)
122
+ for known_type, local_class in _LOCAL_CLASSES:
123
+ if known_type.value == object_type:
124
+ return local_class(entity)
125
+ obj = MidpointObject()
126
+ obj['OID'] = entity.attrib.get('oid')
127
+ obj['Name'] = safe_get_text(entity.find('c:name', namespaces))
128
+ return obj
129
+
130
+
131
+ def _expected_total(task: MidpointTask) -> int | None:
132
+ """The number of items the task expects to process, or None when the server does not tell."""
133
+ try:
134
+ total = int(task['Expected Total'])
135
+ except (TypeError, ValueError):
136
+ return None
137
+ return total if total > 0 else None
138
+
139
+
140
+ # A task the CLI creates is started by the server's scheduler, usually within a second or two.
141
+ # Until then it has no result; waiting stops after TASK_START_ATTEMPTS polls.
142
+ TASK_START_POLL_SECONDS = 1
143
+ TASK_START_ATTEMPTS = 120
144
+
145
+
146
+ class MidpointClient:
147
+ def __init__(
148
+ self,
149
+ client_configuration: MidpointClientConfiguration | None = None,
150
+ api_client: RestApiClient | None = None,
151
+ observer: MidpointCommunicationObserver | None = None,
152
+ ):
153
+ self._api_client: RestApiClient | None = None
154
+
155
+ if client_configuration is not None:
156
+ self._api_client = RestApiClient(client_configuration, observer=observer)
157
+
158
+ if api_client is not None:
159
+ self._api_client = api_client
160
+
161
+ # Server version, detected on first use (get_server_version); None until then or if unknown.
162
+ self._server_version: MidpointVersion | None = None
163
+ self._server_version_detected = False
164
+
165
+ @property
166
+ def api_client(self) -> RestApiClient:
167
+ if self._api_client is None:
168
+ raise MidpointClientNotConfigured(
169
+ 'MidpointClient was created without a client configuration or a REST API client'
170
+ )
171
+ return self._api_client
172
+
173
+ @api_client.setter
174
+ def api_client(self, api_client: RestApiClient) -> None:
175
+ self._api_client = api_client
176
+
177
+ def __get_object(self, midpoint_type: MidpointObjectType, oid: str) -> Element:
178
+ response = self.api_client.get_element(midpoint_type, oid)
179
+ tree = safe_fromstring(response)
180
+
181
+ status = tree.find('c:status', namespaces)
182
+
183
+ if status is not None and status.text == ResultStatus.FATAL_ERROR:
184
+ message_node = tree.find('c:partialResults/c:message', namespaces)
185
+ message = message_node.text if message_node is not None else 'Unknown error'
186
+ raise MidpointServerError(message)
187
+
188
+ return tree
189
+
190
+ def __get_collection(
191
+ self,
192
+ mp_class: MidpointObjectType,
193
+ local_class: type,
194
+ limit: int | None = None,
195
+ name_filter: str | None = None,
196
+ ) -> MidpointObjectList:
197
+ """List a collection; with a limit or a name filter, the server filters and pages it."""
198
+ if limit is None and not name_filter:
199
+ tree = self.api_client.get_elements(mp_class)
200
+ return MidpointObjectList([local_class(entity) for entity in tree])
201
+ return self.search_objects(mp_class, ObjectQuery.listing(limit=limit, name_filter=name_filter))
202
+
203
+ def search_objects(
204
+ self, object_type: MidpointObjectType, query: ObjectQuery, options: Sequence[str] = ()
205
+ ) -> MidpointObjectList:
206
+ """Run a server-side search (POST ws/rest/<collection>/search) and parse the matching objects.
207
+
208
+ options are REST GetOperationOptions such as 'raw' (see RestApiClient.search_elements).
209
+ """
210
+ if options:
211
+ tree = self.api_client.search_elements(object_type, query.to_xml(), options)
212
+ else:
213
+ tree = self.api_client.search_elements(object_type, query.to_xml())
214
+ server_version = self._server_version_for(object_type)
215
+ return MidpointObjectList(
216
+ [
217
+ _parse_object(object_type, entity, server_version)
218
+ for entity in tree
219
+ if entity.attrib.get('oid') is not None
220
+ ]
221
+ )
222
+
223
+ def _server_version_for(self, object_type: MidpointObjectType) -> MidpointVersion | None:
224
+ """The server version for parsing objects of object_type: only tasks need it."""
225
+ return self.get_server_version() if object_type == MidpointObjectTypes.TASK.value else None
226
+
227
+ def get_object(self, object_type: MidpointObjectType, oid: str) -> MidpointObject:
228
+ """Fetch one object by OID, parsed with the local class of its type."""
229
+ return _parse_object(object_type, self.__get_object(object_type, oid), self._server_version_for(object_type))
230
+
231
+ def find_objects(self, object_type: MidpointObjectType, reference: str) -> MidpointObjectList:
232
+ """Every object a CLI reference designates: the object with that OID, or else all objects with that name.
233
+
234
+ A reference in OID form is fetched directly. Otherwise the server is searched by exact name;
235
+ when nothing has that name, the reference is tried as an OID (the generic repository allows
236
+ OIDs that are not UUIDs). An empty list means nothing matched.
237
+ """
238
+ if not is_oid(reference):
239
+ matches = self.search_objects(object_type, ObjectQuery.name_equals(reference))
240
+ if matches:
241
+ return matches
242
+ try:
243
+ return MidpointObjectList([self.get_object(object_type, reference)])
244
+ except MidpointObjectNotFound:
245
+ return MidpointObjectList()
246
+ except MidpointServerError:
247
+ # The search above reached the server, so this is the reference being refused as an
248
+ # OID (unsafe characters, or the native repository rejecting a non-UUID OID).
249
+ if is_oid(reference):
250
+ raise
251
+ return MidpointObjectList()
252
+
253
+ def find_object(self, object_type: MidpointObjectType, reference: str) -> MidpointObject | None:
254
+ """The single object a CLI reference (OID or name) designates, or None when nothing matches.
255
+
256
+ Raises MidpointAmbiguousName when the reference is a name shared by several objects,
257
+ instead of silently acting on one of them.
258
+ """
259
+ matches = self.find_objects(object_type, reference)
260
+ if len(matches) > 1:
261
+ raise MidpointAmbiguousName(object_type, reference, [str(m.get_oid()) for m in matches])
262
+ return matches[0] if matches else None
263
+
264
+ def resolve_oid(self, object_type: MidpointObjectType, reference: str) -> str:
265
+ """The OID a CLI reference designates: a canonical OID as is, else the OID of the object so named.
266
+
267
+ Raises MidpointObjectNotFound when nothing has that name or OID, and MidpointAmbiguousName
268
+ when several objects have that name.
269
+ """
270
+ if is_oid(reference):
271
+ return reference
272
+ found = self.find_object(object_type, reference)
273
+ oid = found.get_oid() if found is not None else None
274
+ if oid is None:
275
+ raise MidpointObjectNotFound(f'No {object_type.tagname} has the name or OID {reference!r}')
276
+ return oid
277
+
278
+ def complete_names(self, object_type: MidpointObjectType, prefix: str, limit: int = 50) -> MidpointObjectList:
279
+ """At most limit objects whose name starts with prefix, for tab completion."""
280
+ return self.search_objects(object_type, ObjectQuery.name_prefix(prefix, limit))
281
+
282
+ def get_tasks(
283
+ self, limit: int | None = None, name_filter: str | None = None, statuses: Sequence[str] = ()
284
+ ) -> MidpointObjectList:
285
+ """List tasks; statuses keeps those in any of the `task ls --status` states (TASK_STATUS_FILTERS)."""
286
+ if limit is None and not name_filter and not statuses:
287
+ server_version = self.get_server_version()
288
+ tree = self.api_client.get_elements(MidpointObjectTypes.TASK.value)
289
+ return MidpointObjectList([MidpointTask(entity, server_version) for entity in tree])
290
+ status_filter = task_status_filter(statuses)
291
+ return self.search_objects(
292
+ MidpointObjectTypes.TASK.value,
293
+ ObjectQuery.listing(
294
+ limit=limit, name_filter=name_filter, filters=[status_filter] if status_filter is not None else []
295
+ ),
296
+ )
297
+
298
+ def get_resources(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
299
+ return self.__get_collection(MidpointObjectTypes.RESOURCE.value, MidpointResource, limit, name_filter)
300
+
301
+ def get_resource_shadow_counts(self, resource_oid: str) -> dict[str, int]:
302
+ """Count the repository shadows of a resource by synchronization situation.
303
+
304
+ Returns a count for each of SHADOW_SITUATIONS, plus 'none' for shadows without a
305
+ situation and 'total'. Midpoint 4.10+ counts on the server (6 searches answering one
306
+ shadow each); earlier versions cannot count, so all shadows of the resource are
307
+ downloaded in one search and counted here, which is slow on large resources.
308
+ """
309
+ by_resource = ref_filter('resourceRef', resource_oid)
310
+ version = self.get_server_version()
311
+ if version is None or version.at_least(4, 10):
312
+ total = self.api_client.count_elements(
313
+ MidpointObjectTypes.SHADOW.value,
314
+ ObjectQuery(filter=by_resource, limit=1).to_xml(),
315
+ _SHADOW_COUNT_OPTIONS,
316
+ )
317
+ if total is not None:
318
+ counts = dict.fromkeys(SHADOW_SITUATIONS, 0)
319
+ for situation in SHADOW_SITUATIONS if total else ():
320
+ situation_filter = and_filter([by_resource, equal_filter('synchronizationSituation', situation)])
321
+ counts[situation] = (
322
+ self.api_client.count_elements(
323
+ MidpointObjectTypes.SHADOW.value,
324
+ ObjectQuery(filter=situation_filter, limit=1).to_xml(),
325
+ _SHADOW_COUNT_OPTIONS,
326
+ )
327
+ or 0
328
+ )
329
+ counts['none'] = max(total - sum(counts.values()), 0)
330
+ counts['total'] = total
331
+ return counts
332
+
333
+ shadows = self.api_client.search_elements(
334
+ MidpointObjectTypes.SHADOW.value, ObjectQuery(filter=by_resource).to_xml(), _SHADOW_COUNT_OPTIONS
335
+ )
336
+ counts = dict.fromkeys((*SHADOW_SITUATIONS, 'none', 'total'), 0)
337
+ for shadow in shadows:
338
+ if shadow.attrib.get('oid') is None:
339
+ continue
340
+ situation = safe_get_text(shadow.find('c:synchronizationSituation', namespaces)).strip()
341
+ counts[situation if situation in SHADOW_SITUATIONS else 'none'] += 1
342
+ counts['total'] += 1
343
+ return counts
344
+
345
+ def get_connectors(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
346
+ return self.__get_collection(MidpointObjectTypes.CONNECTOR.value, MidpointConnector, limit, name_filter)
347
+
348
+ def get_reports(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
349
+ return self.search_objects(
350
+ MidpointObjectTypes.REPORT.value, ObjectQuery.listing(limit=limit, name_filter=name_filter)
351
+ )
352
+
353
+ def get_shadows(
354
+ self,
355
+ resource_oid: str | None = None,
356
+ kind: str | None = None,
357
+ intent: str | None = None,
358
+ limit: int | None = None,
359
+ name_filter: str | None = None,
360
+ ) -> MidpointObjectList:
361
+ """Shadows from the repository, optionally of one resource, kind and intent.
362
+
363
+ The search is raw: without it the model would hand a shadow search to provisioning,
364
+ which reads the resource itself and wants an object class or kind. A raw search
365
+ reads the repository only, so it lists what Midpoint knows without contacting the resource.
366
+ """
367
+ filters = []
368
+ if resource_oid is not None:
369
+ filters.append(ref_filter('resourceRef', resource_oid))
370
+ if kind:
371
+ filters.append(equal_filter('kind', kind))
372
+ if intent:
373
+ filters.append(equal_filter('intent', intent))
374
+ query = ObjectQuery.listing(limit=limit, name_filter=name_filter, filters=filters)
375
+ return self.search_objects(MidpointObjectTypes.SHADOW.value, query, options=['raw'])
376
+
377
+ def get_objects_by_oid(self, object_type: MidpointObjectType, oids: Sequence[str]) -> MidpointObjectList:
378
+ """The objects of one type with the given OIDs, in one search; unknown OIDs are skipped."""
379
+ if not oids:
380
+ return MidpointObjectList()
381
+ return self.search_objects(object_type, ObjectQuery(filter=in_oid_filter(oids)))
382
+
383
+ def search_text(
384
+ self,
385
+ object_type: MidpointObjectType,
386
+ query_text: str | None,
387
+ limit: int | None = None,
388
+ raw: bool = False,
389
+ ) -> MidpointObjectList:
390
+ """Objects of one type matching a Midpoint Query Language filter (all objects without one), by name.
391
+
392
+ raw searches the repository only (see get_shadows), without contacting resources.
393
+ """
394
+ query = ObjectQuery.text(query_text, limit=limit) if query_text else ObjectQuery.listing(limit=limit)
395
+ return self.search_objects(object_type, query, options=['raw'] if raw else [])
396
+
397
+ def get_user(self, oid: str) -> MidpointUser:
398
+ user_root = self.__get_object(MidpointObjectTypes.USER.value, oid)
399
+ user = MidpointUser(user_root)
400
+ return user
401
+
402
+ def get_users(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
403
+ return self.__get_collection(MidpointObjectTypes.USER.value, MidpointUser, limit, name_filter)
404
+
405
+ def get_orgs(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
406
+ return self.__get_collection(MidpointObjectTypes.ORG.value, MidpointOrganization, limit, name_filter)
407
+
408
+ def get_orgs_by_oid(self, oids: list[str]) -> MidpointObjectList:
409
+ """The organizations with the given OIDs, in one search; unknown OIDs are skipped."""
410
+ if not oids:
411
+ return MidpointObjectList()
412
+ return self.search_objects(MidpointObjectTypes.ORG.value, ObjectQuery.oids(oids))
413
+
414
+ def get_roles(self, limit: int | None = None, name_filter: str | None = None) -> MidpointObjectList:
415
+ return self.__get_collection(MidpointObjectTypes.ROLE.value, MidpointRole, limit, name_filter)
416
+
417
+ def search_users(self, terms: list[str]) -> MidpointObjectList:
418
+ """Users whose name, full name or email address contains the terms (see ObjectQuery.terms)."""
419
+ return self.search_objects(MidpointObjectTypes.USER.value, ObjectQuery.terms(terms, USER_SEARCH_PATHS))
420
+
421
+ def search_roles(self, terms: list[str]) -> MidpointObjectList:
422
+ """Roles whose name or display name contains the terms (see ObjectQuery.terms)."""
423
+ return self.search_objects(MidpointObjectTypes.ROLE.value, ObjectQuery.terms(terms, ROLE_SEARCH_PATHS))
424
+
425
+ def search_orgs(self, terms: list[str]) -> MidpointObjectList:
426
+ """Organizations whose name or display name contains the terms (see ObjectQuery.terms)."""
427
+ return self.search_objects(MidpointObjectTypes.ORG.value, ObjectQuery.terms(terms, ORG_SEARCH_PATHS))
428
+
429
+ def _modify(
430
+ self, object_type: MidpointObjectType, oid: str, modification_type: str, path: str, value_xml: str
431
+ ) -> None:
432
+ """Apply one item delta to an object: PATCH ws/rest/<collection>/<oid> with an ObjectModificationType."""
433
+ xml_body = (
434
+ '<objectModification'
435
+ f" xmlns='{API_TYPES_NS}'"
436
+ f" xmlns:c='{COMMON_NS}'"
437
+ f" xmlns:t='{TYPES_NS}'>"
438
+ '<itemDelta>'
439
+ f'<t:modificationType>{modification_type}</t:modificationType>'
440
+ f'<t:path>{path}</t:path>'
441
+ f'<t:value>{value_xml}</t:value>'
442
+ '</itemDelta>'
443
+ '</objectModification>'
444
+ )
445
+ self.api_client.patch_element(object_type, oid, xml_body)
446
+
447
+ @staticmethod
448
+ def _target_ref(target_type: MidpointObjectType, target_oid: str) -> str:
449
+ return f"<c:targetRef oid={quoteattr(target_oid)} type='c:{target_type.object_type}'/>"
450
+
451
+ def assign(self, user_oid: str, target_type: MidpointObjectType, target_oid: str) -> None:
452
+ """Add an assignment of a role, org, service or archetype (target_type) to a user."""
453
+ self._modify(
454
+ MidpointObjectTypes.USER.value, user_oid, 'add', 'c:assignment', self._target_ref(target_type, target_oid)
455
+ )
456
+
457
+ def unassign(self, user_oid: str, target_type: MidpointObjectType, target_oid: str) -> None:
458
+ """Remove a user's assignment of a role, org, service or archetype, matched by its target."""
459
+ self._modify(
460
+ MidpointObjectTypes.USER.value,
461
+ user_oid,
462
+ 'delete',
463
+ 'c:assignment',
464
+ self._target_ref(target_type, target_oid),
465
+ )
466
+
467
+ def assign_role(self, user_oid: str, role_oid: str) -> None:
468
+ self.assign(user_oid, MidpointObjectTypes.ROLE.value, role_oid)
469
+
470
+ def unassign_role(self, user_oid: str, role_oid: str) -> None:
471
+ self.unassign(user_oid, MidpointObjectTypes.ROLE.value, role_oid)
472
+
473
+ def set_user_enabled(self, user_oid: str, enabled: bool) -> None:
474
+ """Enable or disable a user by replacing activation/administrativeStatus."""
475
+ status = 'enabled' if enabled else 'disabled'
476
+ self._modify(MidpointObjectTypes.USER.value, user_oid, 'replace', 'c:activation/c:administrativeStatus', status)
477
+
478
+ def set_user_password(self, user_oid: str, password: str) -> None:
479
+ """Replace a user's password with a protected string in clear (t:clearValue); the server encrypts it.
480
+
481
+ The password only travels in the request body: it is never logged, and errors carry the
482
+ server's message, not the request.
483
+ """
484
+ self._modify(
485
+ MidpointObjectTypes.USER.value,
486
+ user_oid,
487
+ 'replace',
488
+ 'c:credentials/c:password/c:value',
489
+ f'<t:clearValue>{xml_escape(password)}</t:clearValue>',
490
+ )
491
+
492
+ def get_resource(self, oid: str) -> MidpointResource:
493
+ return MidpointResource(self.__get_object(MidpointObjectTypes.RESOURCE.value, oid))
494
+
495
+ def get_role(self, oid: str) -> MidpointRole:
496
+ return MidpointRole(self.__get_object(MidpointObjectTypes.ROLE.value, oid))
497
+
498
+ def get_org(self, oid: str) -> MidpointOrganization:
499
+ return MidpointOrganization(self.__get_object(MidpointObjectTypes.ORG.value, oid))
500
+
501
+ def get_shadow(self, oid: str) -> MidpointShadow:
502
+ return MidpointShadow(self.__get_object(MidpointObjectTypes.SHADOW.value, oid))
503
+
504
+ def task_action(
505
+ self,
506
+ task_oid: str,
507
+ task_action: str,
508
+ progress_monitor: ProgressMonitor,
509
+ show_changes: bool = True,
510
+ ) -> MidpointTask | None:
511
+ run_started_at = datetime.now(UTC)
512
+ self.api_client.execute_action(MidpointObjectTypes.TASK.value, task_oid, task_action)
513
+
514
+ if task_action == 'run':
515
+ return self.task_wait(task_oid, progress_monitor, show_changes=show_changes, run_started_at=run_started_at)
516
+
517
+ return None
518
+
519
+ def task_wait(
520
+ self,
521
+ task_oid: str,
522
+ progress_monitor: ProgressMonitor,
523
+ show_changes: bool = True,
524
+ run_started_at: datetime | None = None,
525
+ ) -> MidpointTask:
526
+ server_version = self.get_server_version()
527
+ # First, get the initial task state to extract start time
528
+ task_root = self.__get_object(MidpointObjectTypes.TASK.value, task_oid)
529
+ initial_task = MidpointTask(task_root, server_version)
530
+
531
+ # Set the task start time on the progress monitor before entering
532
+ task_start_time = initial_task.get_start_time()
533
+ progress_monitor.set_task_start_time(task_start_time)
534
+
535
+ # Audit-stream state — only used when show_changes is on and the task
536
+ # advertises a taskIdentifier (the join key into audit records).
537
+ task_identifier = initial_task.get_task_identifier() if show_changes else None
538
+ audit_cursor = initial_audit_cursor(run_started_at, task_start_time)
539
+ seen_event_ids: set[str] = set()
540
+ # Set to False on the first MidpointServerError so we stop retrying on
541
+ # servers that refuse or cannot run the audit script.
542
+ audit_available: bool = True
543
+
544
+ with progress_monitor as progress:
545
+ # Update with initial progress and statistics
546
+ initial_progress = int(initial_task['Progress'] or '0')
547
+ initial_success = initial_task.get_success_count()
548
+ initial_failure = initial_task.get_failure_count()
549
+ progress.set_expected_total(_expected_total(initial_task))
550
+ progress.update_with_stats(initial_progress, initial_success, initial_failure)
551
+
552
+ # Drain any audit events that already happened before we attached.
553
+ if task_identifier and audit_available:
554
+ try:
555
+ audit_cursor = self._stream_task_audit_events(
556
+ task_identifier, audit_cursor, seen_event_ids, progress
557
+ )
558
+ except MidpointServerError:
559
+ audit_available = False
560
+
561
+ # Check if task is already completed
562
+ rstatus = initial_task['Result Status']
563
+ if rstatus != ResultStatus.IN_PROGRESS:
564
+ print(file=sys.stderr) # ends the progress line, which is on stderr
565
+ if rstatus != ResultStatus.SUCCESS:
566
+ raise TaskExecutionFailure(
567
+ 'Failed execution of task ' + task_oid + ' with status ' + rstatus, initial_task
568
+ )
569
+ return initial_task
570
+
571
+ # Continue polling for updates
572
+ while True:
573
+ time.sleep(2)
574
+ task_root = self.__get_object(MidpointObjectTypes.TASK.value, task_oid)
575
+ task = MidpointTask(task_root, server_version)
576
+
577
+ # Update with progress and statistics
578
+ current_progress = int(task['Progress'] or '0')
579
+ current_success = task.get_success_count()
580
+ current_failure = task.get_failure_count()
581
+ progress.set_expected_total(_expected_total(task))
582
+ progress.update_with_stats(current_progress, current_success, current_failure)
583
+
584
+ if task_identifier and audit_available:
585
+ try:
586
+ audit_cursor = self._stream_task_audit_events(
587
+ task_identifier, audit_cursor, seen_event_ids, progress
588
+ )
589
+ except MidpointServerError:
590
+ audit_available = False
591
+
592
+ rstatus = task['Result Status']
593
+
594
+ if rstatus != ResultStatus.IN_PROGRESS:
595
+ # One last drain so trailing events that landed between the
596
+ # final fetch and the status flip aren't lost.
597
+ if task_identifier and audit_available:
598
+ with contextlib.suppress(MidpointServerError):
599
+ self._stream_task_audit_events(task_identifier, audit_cursor, seen_event_ids, progress)
600
+ print(file=sys.stderr) # ends the progress line, which is on stderr
601
+ if rstatus != ResultStatus.SUCCESS:
602
+ raise TaskExecutionFailure(
603
+ 'Failed execution of task ' + task_oid + ' with status ' + rstatus, task
604
+ )
605
+
606
+ return task
607
+
608
+ def _stream_task_audit_events(
609
+ self,
610
+ task_identifier: str,
611
+ cursor: datetime | None,
612
+ seen: set[str],
613
+ progress: ProgressMonitor,
614
+ ) -> datetime | None:
615
+ """Fetch new audit events for the running task and push each through the monitor.
616
+
617
+ The cursor is inclusive, so events sharing the newest timestamp already seen are
618
+ fetched again; ``seen`` drops the ones already shown. Returns the advanced cursor.
619
+ """
620
+ if cursor is not None:
621
+ cursor = as_utc(cursor)
622
+ events = self.search_audit(
623
+ AuditQuery(task_identifier=task_identifier, since=cursor, since_inclusive=True, limit=0)
624
+ )
625
+ # Audit search returns descending by timestamp; replay chronologically.
626
+ for ev in reversed(events):
627
+ event_id = ev.get('EventIdentifier')
628
+ if not event_id or event_id in seen:
629
+ continue
630
+ seen.add(event_id)
631
+ progress.log_event(ev)
632
+ ts_text = ev.get('Timestamp')
633
+ ts = parse_audit_timestamp(ts_text) if ts_text else None
634
+ if ts and (cursor is None or ts > cursor):
635
+ cursor = ts
636
+ return cursor
637
+
638
+ def create_task(self, task_xml: str) -> str:
639
+ """Create a task (POST ws/rest/tasks) and return its OID; a runnable task is started by the server."""
640
+ oid = self.api_client.create_element(MidpointObjectTypes.TASK.value, task_xml)
641
+ if oid is None:
642
+ raise MidpointServerError('The server accepted the task but did not report its OID')
643
+ return oid
644
+
645
+ def start_resource_task(
646
+ self,
647
+ operation: str,
648
+ resource: MidpointObject,
649
+ kind: str | None = None,
650
+ intent: str | None = None,
651
+ object_class: str | None = None,
652
+ ) -> tuple[str, str]:
653
+ """Create and start an import ('import') or reconciliation ('reconcile') task for a resource.
654
+
655
+ Returns the OID and name of the new task.
656
+ """
657
+ resource_oid = resource.get_oid()
658
+ if resource_oid is None:
659
+ raise AssertionError('Resource OID cannot be None')
660
+ name = resource_task_name(operation, resource.get_name() or resource_oid, kind, intent, object_class)
661
+ task_xml = resource_task_xml(operation, name, resource_oid, kind, intent, object_class)
662
+ return self.create_task(task_xml), name
663
+
664
+ def start_report_task(self, report: MidpointObject) -> tuple[str, str]:
665
+ """Create and start a classic export task for a report; returns the OID and name of the new task."""
666
+ report_oid = report.get_oid()
667
+ if report_oid is None:
668
+ raise AssertionError('Report OID cannot be None')
669
+ name = report_task_name(report.get_name() or report_oid)
670
+ return self.create_task(report_task_xml(name, report_oid)), name
671
+
672
+ def wait_for_task_start(self, task_oid: str) -> None:
673
+ """Wait until a newly created task has started (it has a result) or was stopped.
674
+
675
+ task_wait reads a task without a result as failed, so a new task is only handed to it
676
+ once the scheduler has picked it up.
677
+ """
678
+ for attempt in range(TASK_START_ATTEMPTS):
679
+ task = self.get_object(MidpointObjectTypes.TASK.value, task_oid)
680
+ if task.get('Result Status') or task.get('Execution Status') in (
681
+ TaskExecutionState.SUSPENDED,
682
+ TaskExecutionState.CLOSED,
683
+ ):
684
+ return
685
+ if attempt + 1 < TASK_START_ATTEMPTS:
686
+ time.sleep(TASK_START_POLL_SECONDS)
687
+ raise TaskExecutionFailure(
688
+ f'Task {task_oid} did not start within {TASK_START_ATTEMPTS * TASK_START_POLL_SECONDS} seconds; '
689
+ f'it is still waiting for the server to run it'
690
+ )
691
+
692
+ def run_new_task(
693
+ self,
694
+ task_oid: str,
695
+ progress_monitor: ProgressMonitor,
696
+ show_changes: bool = True,
697
+ run_started_at: datetime | None = None,
698
+ ) -> MidpointTask:
699
+ """Wait for a task created by create_task to start, then follow it to completion like task_wait."""
700
+ self.wait_for_task_start(task_oid)
701
+ return self.task_wait(task_oid, progress_monitor, show_changes=show_changes, run_started_at=run_started_at)
702
+
703
+ def test_resource(self, resource_oid: str) -> str:
704
+ response = self.api_client.execute_action(MidpointObjectTypes.RESOURCE.value, resource_oid, 'test')
705
+ tree = safe_fromstring(response)
706
+ status_node = tree.find('c:status', namespaces)
707
+ return status_node.text if status_node is not None and status_node.text is not None else 'unknown'
708
+
709
+ def get_xml(self, midpoint_type: MidpointObjectType, oid: str) -> str:
710
+ return self.api_client.get_element(midpoint_type, oid)
711
+
712
+ def put_xml(self, xml_file: str, patch_file: str | None = None, patch_write: bool = False) -> PutResult:
713
+ return self.api_client.put_element(xml_file, patch_file, patch_write)
714
+
715
+ def delete(self, midpoint_type: MidpointObjectType, oid: str) -> str:
716
+ return self.api_client.delete(midpoint_type, oid)
717
+
718
+ def search_audit(self, query: AuditQuery) -> MidpointObjectList:
719
+ """Run an audit search and return the matching events as MidpointAuditEvent objects.
720
+
721
+ Tunnels through ``rpc/executeScript`` because Midpoint has no REST endpoint
722
+ for audit search. Midpoint 4.11+ refuses that Groovy script by default; such
723
+ refusals are raised as ``MidpointAuditScriptRefused`` with the settings to change.
724
+ A script that does not compile or link against the server's internal classes
725
+ is raised as ``MidpointAuditScriptIncompatible``, naming the server version.
726
+ """
727
+ server_version = self.get_server_version()
728
+ try:
729
+ response_xml = self.api_client.post_xml(
730
+ AUDIT_EXEC_SCRIPT_PATH, build_execute_script_body(query, server_version)
731
+ )
732
+ except MidpointServerError as e:
733
+ replacement = audit_script_error(e, server_version)
734
+ if replacement is not None:
735
+ raise replacement from e
736
+ raise
737
+ return MidpointObjectList(parse_audit_events_from_script_response(response_xml))
738
+
739
+ def get_server_version(self) -> MidpointVersion | None:
740
+ """Return the server's Midpoint version, read once from ``actuator/info`` and cached.
741
+
742
+ Returns None when the server does not tell: callers then fall back to heuristics.
743
+ A failure to reach the server is not cached, so a later call tries again.
744
+ """
745
+ if not self._server_version_detected:
746
+ try:
747
+ self._remember_server_version(self._get_actuator_info())
748
+ except (MidpointServerError, ValueError) as e:
749
+ logger.debug('Could not detect the Midpoint server version: %s', e)
750
+ unreachable = isinstance(e, MidpointMissingPassword) or isinstance(
751
+ e.__cause__, requests.RequestException
752
+ )
753
+ self._server_version_detected = not unreachable
754
+ return self._server_version
755
+
756
+ def _remember_server_version(self, actuator_info: dict[str, Any]) -> None:
757
+ self._server_version = parse_server_version(actuator_info.get('version'))
758
+ self._server_version_detected = True
759
+
760
+ def _get_actuator_info(self) -> dict[str, Any]:
761
+ raw = self.api_client.get_endpoint('actuator/info')
762
+ data = json.loads(raw)
763
+ if not isinstance(data, dict):
764
+ raise ValueError(f'Unexpected actuator/info content: {raw[:100]!r}')
765
+ return data
766
+
767
+ def get_logging_configuration(self) -> LoggingConfiguration:
768
+ """The root logger level and the class loggers of the system configuration."""
769
+ return parse_logging(
770
+ self.__get_object(MidpointObjectTypes.SYSTEM_CONFIGURATION.value, SYSTEM_CONFIGURATION_OID)
771
+ )
772
+
773
+ def set_class_logger(self, package: str, level: str) -> str:
774
+ """Set the level of a class logger in the system configuration, adding the logger if needed.
775
+
776
+ Only the logging items are patched, never the whole system configuration. Returns
777
+ 'added', 'updated', or 'unchanged' when the logger already had that level (nothing is
778
+ sent then). Raises ValueError for an invalid level or logger name.
779
+ """
780
+ level = normalize_level(level)
781
+ package = check_logger_name(package)
782
+ existing = self.get_logging_configuration().loggers_of(package)
783
+ if existing and all(logger.level == level for logger in existing):
784
+ return 'unchanged'
785
+ self.api_client.patch_element(
786
+ MidpointObjectTypes.SYSTEM_CONFIGURATION.value,
787
+ SYSTEM_CONFIGURATION_OID,
788
+ class_logger_modification(existing, package, level),
789
+ )
790
+ return 'updated' if existing else 'added'
791
+
792
+ def get_system_info(self) -> dict[str, str]:
793
+ """The properties `info` shows, by name (see get_server_information)."""
794
+ return self.get_server_information().properties
795
+
796
+ def get_server_information(self) -> ServerInformation:
797
+ """Gather what `info` shows from actuator/info, the nodes, the system configuration and ws/rest/self.
798
+
799
+ Each source is read on its own, so one the account may not read (403) or a proxy hides
800
+ (404) only leaves its properties out and is listed in ``unavailable``. A failure every
801
+ other call would repeat (no answer, no password, 401) is raised at once, as is the
802
+ first error when no source could be read.
803
+ """
804
+ info = ServerInformation()
805
+ failures: list[MidpointServerError] = []
806
+
807
+ def read(source: str, reader: Callable[[], Any]) -> Any:
808
+ try:
809
+ return reader()
810
+ except (MidpointServerError, ValueError, ElementTree.ParseError) as e:
811
+ if isinstance(e, MidpointServerError) and _repeats_on_every_call(e):
812
+ raise
813
+ logger.debug('Could not read %s: %s', source, e)
814
+ info.unavailable.append(f'{source}: {e}')
815
+ failures.append(e if isinstance(e, MidpointServerError) else MidpointServerError(f'{source}: {e}'))
816
+ return None
817
+
818
+ actuator = read('actuator/info', self._get_actuator_info)
819
+ nodes = read('nodes', lambda: parse_nodes(self.api_client.get_elements(MidpointObjectTypes.NODE.value)))
820
+ system_configuration = read(
821
+ 'system configuration',
822
+ lambda: self.__get_object(MidpointObjectTypes.SYSTEM_CONFIGURATION.value, SYSTEM_CONFIGURATION_OID),
823
+ )
824
+ logged_in = read(
825
+ 'ws/rest/self', lambda: parse_logged_in_user(safe_fromstring(self.api_client.get_endpoint('ws/rest/self')))
826
+ )
827
+
828
+ if actuator is not None:
829
+ if not self._server_version_detected:
830
+ self._remember_server_version(actuator)
831
+ for key, label in ACTUATOR_FIELDS:
832
+ if actuator.get(key):
833
+ info.properties[label] = str(actuator[key])
834
+
835
+ # Without actuator/info, the build of the nodes still tells the version.
836
+ node_versions = sorted({node.version for node in nodes or [] if node.version})
837
+ version = self._server_version
838
+ if 'Midpoint Version' not in info.properties and node_versions:
839
+ info.properties['Midpoint Version'] = ', '.join(node_versions)
840
+ if len(node_versions) == 1:
841
+ version = parse_server_version(node_versions[0])
842
+
843
+ repository = describe_repository(version)
844
+ if repository is not None:
845
+ info.properties['Repository'] = repository
846
+ if system_configuration is not None:
847
+ info.properties.update(parse_deployment(system_configuration))
848
+ if nodes is not None:
849
+ info.properties['Nodes'] = str(len(nodes))
850
+ for node in nodes:
851
+ info.properties[f'Node {node.identifier}'] = node.describe()
852
+ if logged_in is not None:
853
+ info.properties['Logged In As'] = logged_in
854
+
855
+ if not info.properties and failures:
856
+ raise failures[0]
857
+ return info
858
+
859
+
860
+ def _repeats_on_every_call(error: MidpointServerError) -> bool:
861
+ """Whether every further REST call would fail the same way: no password, no answer, or rejected credentials."""
862
+ return (
863
+ isinstance(error, MidpointMissingPassword)
864
+ or isinstance(error.__cause__, requests.RequestException)
865
+ or error.status_code == 401
866
+ )