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,234 @@
1
+ import getpass
2
+ import importlib
3
+ import importlib.metadata
4
+ import logging
5
+ import os
6
+ import shlex
7
+ import sys
8
+ from argparse import REMAINDER, ArgumentParser, ArgumentTypeError, RawTextHelpFormatter
9
+
10
+ from midpoint_cli.client import MidpointClient
11
+ from midpoint_cli.client.session import (
12
+ READ_TIMEOUT_ENV_VAR,
13
+ READ_TIMEOUT_SECONDS,
14
+ RETRY_TIMEOUT_ENV_VAR,
15
+ RETRY_TIMEOUT_SECONDS,
16
+ parse_retry_timeout,
17
+ )
18
+ from midpoint_cli.prompt import MidpointClientPrompt
19
+ from midpoint_cli.prompt.configuration import (
20
+ CONFIGURATION_FILE_VARIABLE,
21
+ SOURCE_DEFAULT,
22
+ ConfigurationFileError,
23
+ ResolvedConfiguration,
24
+ configuration_file_permission_warning,
25
+ ignored_working_directory_file,
26
+ is_unencrypted_remote_url,
27
+ load_configuration_file,
28
+ locate_configuration_file,
29
+ resolve_client_configuration,
30
+ )
31
+ from midpoint_cli.prompt.console import OUTPUT_FORMATS, ConsoleDisplay, default_output_format, print_info
32
+
33
+
34
+ def _build_commands_epilog(version: str) -> str:
35
+ skip = {'EOF', 'exit'}
36
+ commands = []
37
+ for name in sorted(dir(MidpointClientPrompt)):
38
+ if name.startswith('do_'):
39
+ cmd = name[3:]
40
+ if cmd in skip:
41
+ continue
42
+ method = getattr(MidpointClientPrompt, name)
43
+ doc = method.__doc__.strip().splitlines()[0] if method.__doc__ else ''
44
+ commands.append((cmd, doc))
45
+
46
+ if not commands:
47
+ return ''
48
+
49
+ width = max(len(cmd) for cmd, _ in commands) + 2
50
+ lines = ['', 'Available commands:']
51
+ for cmd, doc in commands:
52
+ lines.append(f' {cmd:<{width}}{doc}')
53
+
54
+ lines += [
55
+ '',
56
+ 'Server URL and credentials can also be stored in ~/.midpoint-cli.cfg, or in a file',
57
+ 'named with --config or MIDPOINT_CLI_CONFIG, or read from environment variables',
58
+ 'MIDPOINT_URL, MIDPOINT_USERNAME and MIDPOINT_PASSWORD.',
59
+ 'Prefer those, or -P, over -p: a password given on the command line is visible to other users.',
60
+ f'The retry timeout (--timeout) can also be set with {RETRY_TIMEOUT_ENV_VAR} or a timeout',
61
+ f'key in the configuration file; it defaults to {RETRY_TIMEOUT_SECONDS:g} seconds.',
62
+ f'The read timeout (--read-timeout) can also be set with {READ_TIMEOUT_ENV_VAR} or a',
63
+ f'read_timeout key in the configuration file; it defaults to {READ_TIMEOUT_SECONDS:g} seconds.',
64
+ '',
65
+ f'Midpoint-cli version {version}, created and maintained by Yannick Kirschhoffer alcibiade@alcibiade.org',
66
+ '',
67
+ ]
68
+ return '\n'.join(lines)
69
+
70
+
71
+ def _positive_seconds(raw: str) -> float:
72
+ """argparse type for --timeout and --read-timeout: a finite positive number of seconds."""
73
+ value = parse_retry_timeout(raw)
74
+ if value is None:
75
+ raise ArgumentTypeError(f'expected a positive number of seconds, got {raw!r}')
76
+ return value
77
+
78
+
79
+ def _print_configuration_banner(resolved: ResolvedConfiguration, config_path: str | None) -> None:
80
+ """Tell an interactive user which server and account the session uses, and where they came from."""
81
+ print_info(resolved.describe())
82
+ ignored_file = ignored_working_directory_file(config_path)
83
+ if ignored_file is not None:
84
+ print_info(f'{ignored_file} in the working directory is not read; use --config {ignored_file} to read it.')
85
+
86
+
87
+ def _missing_password_message(config_path: str | None) -> str:
88
+ message = (
89
+ 'no password configured. Use -P to be prompted for it, set MIDPOINT_PASSWORD, '
90
+ 'or add a password to the configuration file'
91
+ )
92
+ message += f' ({config_path}).' if config_path else ' (~/.midpoint-cli.cfg or --config PATH).'
93
+ ignored_file = ignored_working_directory_file(config_path)
94
+ if ignored_file is not None:
95
+ message += f' {ignored_file} in the working directory is not read; use --config {ignored_file} to read it.'
96
+ return message
97
+
98
+
99
+ def main():
100
+ logging.basicConfig(level=logging.INFO)
101
+ logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR)
102
+
103
+ version = importlib.metadata.version('midpoint_cli')
104
+
105
+ MIDPOINT_ENVIRONMENT_VARS = [
106
+ 'MIDPOINT_URL',
107
+ 'MIDPOINT_USERNAME',
108
+ 'MIDPOINT_PASSWORD',
109
+ RETRY_TIMEOUT_ENV_VAR,
110
+ READ_TIMEOUT_ENV_VAR,
111
+ ]
112
+
113
+ argument_parser = ArgumentParser(
114
+ description='An interactive Midpoint command line client.',
115
+ formatter_class=RawTextHelpFormatter,
116
+ epilog=_build_commands_epilog(version),
117
+ # Without this, `audit ls --user X` matched --username by prefix.
118
+ allow_abbrev=False,
119
+ )
120
+ argument_parser.add_argument('-v', '--version', help='Show version information and exit.', action='store_true')
121
+ argument_parser.add_argument(
122
+ '-u', '--username', help='Set the username to authenticate this session.', default=None
123
+ )
124
+ argument_parser.add_argument(
125
+ '-p', '--password', help='Set the password to authenticate this session.', default=None
126
+ )
127
+ argument_parser.add_argument(
128
+ '-P',
129
+ '--ask-password',
130
+ help='Prompt for the password instead of passing it on the command line,\n'
131
+ 'where it would be visible in the process list and the shell history.',
132
+ action='store_true',
133
+ )
134
+ argument_parser.add_argument('-U', '--url', help='Midpoint base URL', default=None)
135
+ argument_parser.add_argument(
136
+ '-t',
137
+ '--timeout',
138
+ metavar='SECONDS',
139
+ type=_positive_seconds,
140
+ help=f'Keep retrying a request the server does not answer for up to SECONDS\n'
141
+ f'(default: {RETRY_TIMEOUT_ENV_VAR}, then the configuration file, then {RETRY_TIMEOUT_SECONDS:g}).',
142
+ default=None,
143
+ )
144
+ argument_parser.add_argument(
145
+ '--read-timeout',
146
+ metavar='SECONDS',
147
+ type=_positive_seconds,
148
+ help=f'Wait up to SECONDS for the answer to each request; a read timeout is not retried\n'
149
+ f'(default: {READ_TIMEOUT_ENV_VAR}, then the configuration file, then {READ_TIMEOUT_SECONDS:g}).',
150
+ default=None,
151
+ )
152
+ argument_parser.add_argument(
153
+ '-c',
154
+ '--config',
155
+ metavar='PATH',
156
+ help=f'Read settings from this configuration file instead of ~/.midpoint-cli.cfg.\n'
157
+ f'Defaults to the {CONFIGURATION_FILE_VARIABLE} environment variable when set.',
158
+ default=None,
159
+ )
160
+ argument_parser.add_argument(
161
+ '-o',
162
+ '--output',
163
+ choices=OUTPUT_FORMATS,
164
+ help='Output format of list commands (ls, search, users, tasks, ...) and of the\n'
165
+ 'user, role, org and task get details: a table, or json, csv or tsv for scripts.\n'
166
+ 'Defaults to table on a terminal and to tsv when the output is redirected or piped.\n'
167
+ 'The output command changes it during a session.',
168
+ default=None,
169
+ )
170
+ argument_parser.add_argument('command', help='Optional command to be executed immediately.', nargs='?')
171
+ # REMAINDER stops top-level option parsing at the command word, so every flag
172
+ # after it (`put -p patch.xml`, `audit ls --user X`, `task run --no-changes`)
173
+ # belongs to the command instead of being taken as a global option.
174
+ argument_parser.add_argument('arg', help='Optional command arguments.', nargs=REMAINDER)
175
+
176
+ argument_namespace = argument_parser.parse_args(sys.argv[1:])
177
+
178
+ if argument_namespace.version:
179
+ print(f'Midpoint CLI Version {version}')
180
+ sys.exit(0)
181
+
182
+ if argument_namespace.ask_password:
183
+ argument_namespace.password = getpass.getpass('Midpoint password: ')
184
+
185
+ config_path, _ = locate_configuration_file(argument_namespace.config, os.getenv(CONFIGURATION_FILE_VARIABLE))
186
+ try:
187
+ config_parser = load_configuration_file(config_path)
188
+ resolved_configuration = resolve_client_configuration(
189
+ argument_namespace, config_parser, [os.getenv(v) for v in MIDPOINT_ENVIRONMENT_VARS], config_path
190
+ )
191
+ except ConfigurationFileError as e:
192
+ argument_parser.exit(2, f'{argument_parser.prog}: error: {e}\n')
193
+ if resolved_configuration.sources['password'] == SOURCE_DEFAULT:
194
+ # Fail closed rather than silently trying the Midpoint demo password.
195
+ if sys.stdin.isatty() and not argument_namespace.ask_password:
196
+ resolved_configuration.set('password', getpass.getpass('Midpoint password: '), 'password prompt')
197
+ if resolved_configuration.sources['password'] == SOURCE_DEFAULT:
198
+ argument_parser.exit(2, f'{argument_parser.prog}: error: {_missing_password_message(config_path)}\n')
199
+
200
+ client_configuration = resolved_configuration.configuration
201
+
202
+ permission_warning = configuration_file_permission_warning(config_path, config_parser, client_configuration.url)
203
+ if permission_warning is not None:
204
+ logging.getLogger(__name__).warning(permission_warning)
205
+
206
+ if is_unencrypted_remote_url(client_configuration.url):
207
+ logging.getLogger(__name__).warning(
208
+ 'Credentials will be sent with HTTP Basic authentication over unencrypted http:// to %s. '
209
+ 'Use an https:// server URL unless the server is on a trusted network.',
210
+ client_configuration.url,
211
+ )
212
+
213
+ client = MidpointClient(client_configuration, observer=ConsoleDisplay())
214
+ prompt = MidpointClientPrompt(
215
+ client,
216
+ resolved_configuration,
217
+ output_format=argument_namespace.output or default_output_format(),
218
+ )
219
+
220
+ if argument_namespace.command is not None:
221
+ parts = [argument_namespace.command, *argument_namespace.arg]
222
+ prompt.onecmd(' '.join(shlex.quote(p) for p in parts))
223
+ sys.exit(prompt.error_code)
224
+ else:
225
+ if sys.stdin.isatty():
226
+ _print_configuration_banner(resolved_configuration, config_path)
227
+ prompt.cmdloop()
228
+ if not sys.stdin.isatty():
229
+ # Commands piped on stdin form a script: fail it with the code of its first failed line.
230
+ sys.exit(prompt.first_error_code)
231
+
232
+
233
+ if __name__ == '__main__':
234
+ main()
@@ -0,0 +1,199 @@
1
+ """Midpoint client package: the public names scripts and the CLI import from ``midpoint_cli.client``.
2
+
3
+ The code lives in focused modules; this package re-exports their public names:
4
+
5
+ - ``midpoint_client``: ``MidpointClient``, the high-level operations;
6
+ - ``rest``: ``RestApiClient``, the REST transport, and ``PutResult``;
7
+ - ``configuration``: ``MidpointClientConfiguration``;
8
+ - ``errors``: the exceptions;
9
+ - ``audit``: audit search support (``AUDIT_CURSOR_CLOCK_MARGIN``);
10
+ - ``objects``: the object types (``MidpointObjectTypes``) and the parsed objects;
11
+ - the helper modules (``object_query``, ``audit_query``, ``server_version``, ``server_info``,
12
+ ``logging_config``, ``task_definitions``, ``session``, ``progress``, ``observer``, ``patch``,
13
+ ``safe_xml``), whose names are re-exported as before.
14
+ """
15
+
16
+ from midpoint_cli.client.audit import AUDIT_CURSOR_CLOCK_MARGIN
17
+ from midpoint_cli.client.audit_query import AUDIT_EXEC_SCRIPT_PATH, AuditQuery, build_execute_script_body
18
+ from midpoint_cli.client.configuration import MidpointClientConfiguration
19
+ from midpoint_cli.client.errors import (
20
+ MidpointAmbiguousName,
21
+ MidpointAuditScriptIncompatible,
22
+ MidpointAuditScriptRefused,
23
+ MidpointClientNotConfigured,
24
+ MidpointInvalidOid,
25
+ MidpointMissingPassword,
26
+ MidpointObjectNotFound,
27
+ MidpointServerError,
28
+ MidpointUnsupportedOperation,
29
+ TaskExecutionFailure,
30
+ )
31
+ from midpoint_cli.client.logging_config import (
32
+ LoggingConfiguration,
33
+ check_logger_name,
34
+ class_logger_modification,
35
+ normalize_level,
36
+ parse_logging,
37
+ )
38
+ from midpoint_cli.client.midpoint_client import (
39
+ SHADOW_SITUATIONS,
40
+ TASK_START_ATTEMPTS,
41
+ TASK_START_POLL_SECONDS,
42
+ MidpointClient,
43
+ )
44
+ from midpoint_cli.client.object_query import (
45
+ ORG_SEARCH_PATHS,
46
+ ROLE_SEARCH_PATHS,
47
+ USER_SEARCH_PATHS,
48
+ ObjectQuery,
49
+ and_filter,
50
+ equal_filter,
51
+ in_oid_filter,
52
+ is_oid,
53
+ ref_filter,
54
+ task_status_filter,
55
+ )
56
+ from midpoint_cli.client.objects import (
57
+ MidpointAuditEvent,
58
+ MidpointConnector,
59
+ MidpointObject,
60
+ MidpointObjectList,
61
+ MidpointObjectType,
62
+ MidpointObjectTypes,
63
+ MidpointOrganization,
64
+ MidpointResource,
65
+ MidpointRole,
66
+ MidpointShadow,
67
+ MidpointTask,
68
+ MidpointUser,
69
+ namespaces,
70
+ safe_get_text,
71
+ )
72
+ from midpoint_cli.client.observer import MidpointCommunicationObserver, NopMidpointCommunicationObserver
73
+ from midpoint_cli.client.patch import patch_from_file
74
+ from midpoint_cli.client.progress import NopProgressMonitor, ProgressMonitor
75
+ from midpoint_cli.client.rest import CONNECT_TIMEOUT_SECONDS, TOTAL_COUNT_HEADER, PutResult, RestApiClient
76
+ from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
77
+ from midpoint_cli.client.server_info import (
78
+ ACTUATOR_FIELDS,
79
+ SYSTEM_CONFIGURATION_OID,
80
+ ServerInformation,
81
+ describe_repository,
82
+ parse_deployment,
83
+ parse_logged_in_user,
84
+ parse_nodes,
85
+ )
86
+ from midpoint_cli.client.server_version import (
87
+ MidpointVersion,
88
+ describe_version,
89
+ groovy_refused_by_default,
90
+ parse_server_version,
91
+ )
92
+ from midpoint_cli.client.session import (
93
+ READ_TIMEOUT_ENV_VAR,
94
+ RETRY_TIMEOUT_ENV_VAR,
95
+ CustomHTTPAdapter,
96
+ CustomRetryManager,
97
+ read_timeout_seconds,
98
+ retry_attempts_for,
99
+ retry_timeout_seconds,
100
+ )
101
+ from midpoint_cli.client.task_definitions import (
102
+ report_task_name,
103
+ report_task_xml,
104
+ resource_task_name,
105
+ resource_task_xml,
106
+ )
107
+
108
+ # Grouped by topic under comments rather than sorted, so RUF022 does not apply.
109
+ __all__ = [ # noqa: RUF022
110
+ # Client, transport and configuration
111
+ 'MidpointClient',
112
+ 'RestApiClient',
113
+ 'MidpointClientConfiguration',
114
+ 'PutResult',
115
+ 'CONNECT_TIMEOUT_SECONDS',
116
+ 'TOTAL_COUNT_HEADER',
117
+ 'SHADOW_SITUATIONS',
118
+ 'TASK_START_ATTEMPTS',
119
+ 'TASK_START_POLL_SECONDS',
120
+ 'AUDIT_CURSOR_CLOCK_MARGIN',
121
+ # Exceptions
122
+ 'MidpointServerError',
123
+ 'MidpointUnsupportedOperation',
124
+ 'MidpointInvalidOid',
125
+ 'MidpointObjectNotFound',
126
+ 'MidpointAmbiguousName',
127
+ 'MidpointMissingPassword',
128
+ 'MidpointClientNotConfigured',
129
+ 'MidpointAuditScriptRefused',
130
+ 'MidpointAuditScriptIncompatible',
131
+ 'TaskExecutionFailure',
132
+ # Object types and parsed objects
133
+ 'MidpointObjectType',
134
+ 'MidpointObjectTypes',
135
+ 'MidpointObject',
136
+ 'MidpointObjectList',
137
+ 'MidpointUser',
138
+ 'MidpointRole',
139
+ 'MidpointOrganization',
140
+ 'MidpointResource',
141
+ 'MidpointConnector',
142
+ 'MidpointShadow',
143
+ 'MidpointTask',
144
+ 'MidpointAuditEvent',
145
+ 'namespaces',
146
+ 'safe_get_text',
147
+ # Queries
148
+ 'ObjectQuery',
149
+ 'AuditQuery',
150
+ 'AUDIT_EXEC_SCRIPT_PATH',
151
+ 'build_execute_script_body',
152
+ 'USER_SEARCH_PATHS',
153
+ 'ROLE_SEARCH_PATHS',
154
+ 'ORG_SEARCH_PATHS',
155
+ 'and_filter',
156
+ 'equal_filter',
157
+ 'in_oid_filter',
158
+ 'is_oid',
159
+ 'ref_filter',
160
+ 'task_status_filter',
161
+ # Server version and information
162
+ 'MidpointVersion',
163
+ 'describe_version',
164
+ 'groovy_refused_by_default',
165
+ 'parse_server_version',
166
+ 'ServerInformation',
167
+ 'ACTUATOR_FIELDS',
168
+ 'SYSTEM_CONFIGURATION_OID',
169
+ 'describe_repository',
170
+ 'parse_deployment',
171
+ 'parse_logged_in_user',
172
+ 'parse_nodes',
173
+ # Logging configuration
174
+ 'LoggingConfiguration',
175
+ 'check_logger_name',
176
+ 'class_logger_modification',
177
+ 'normalize_level',
178
+ 'parse_logging',
179
+ # Tasks created by the CLI
180
+ 'report_task_name',
181
+ 'report_task_xml',
182
+ 'resource_task_name',
183
+ 'resource_task_xml',
184
+ # Progress and communication monitoring
185
+ 'ProgressMonitor',
186
+ 'NopProgressMonitor',
187
+ 'MidpointCommunicationObserver',
188
+ 'NopMidpointCommunicationObserver',
189
+ # HTTP session, XML and patch helpers
190
+ 'CustomHTTPAdapter',
191
+ 'CustomRetryManager',
192
+ 'READ_TIMEOUT_ENV_VAR',
193
+ 'RETRY_TIMEOUT_ENV_VAR',
194
+ 'read_timeout_seconds',
195
+ 'retry_attempts_for',
196
+ 'retry_timeout_seconds',
197
+ 'safe_fromstring',
198
+ 'patch_from_file',
199
+ ]
@@ -0,0 +1,178 @@
1
+ """Audit search support: parsing the executeScript answer and explaining refused or failing scripts."""
2
+
3
+ import logging
4
+ import re
5
+ from datetime import UTC, datetime, timedelta
6
+ from xml.etree import ElementTree
7
+
8
+ from midpoint_cli.client.constants import API_TYPES_NS, AUDIT_NS, COMMON_NS, SCRIPTING_NS, ResultStatus
9
+ from midpoint_cli.client.errors import (
10
+ MidpointAuditScriptIncompatible,
11
+ MidpointAuditScriptRefused,
12
+ MidpointServerError,
13
+ )
14
+ from midpoint_cli.client.objects import MidpointAuditEvent
15
+ from midpoint_cli.client.safe_xml import fromstring as safe_fromstring
16
+ from midpoint_cli.client.server_version import MidpointVersion, describe_version, groovy_refused_by_default
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Midpoint 4.11 runs two independent checks before executing a Groovy script. The safety
22
+ # check runs first and hides the profile check, so a refusal always lists both settings.
23
+ _AUDIT_SCRIPT_REFUSAL_CHECKS = {
24
+ 'is not considered safe': 'config.xml safety check',
25
+ 'is not allowed in expression profile': 'expression profile check',
26
+ }
27
+ _AUDIT_SCRIPT_REFUSAL_HELP = (
28
+ 'Audit search runs a Groovy script on the server, which Midpoint 4.11+ refuses by default '
29
+ '({server} blocked it with the {check}). To enable it: '
30
+ '(1) set midpoint/expressions/safeScriptingLanguagesOnly to false in config.xml and restart Midpoint, '
31
+ 'unless config.xml has no such setting; '
32
+ '(2) add an expression profile allowing Groovy to the system configuration (expressions/expressionProfile) '
33
+ 'and reference it from expressions/defaults/privilegedPrincipal.'
34
+ )
35
+
36
+
37
+ def _audit_script_refusal_check(error_message: str) -> str | None:
38
+ """Name the server check that refused the Groovy script in an executeScript error, or None for other errors."""
39
+ if 'Groovy' not in error_message:
40
+ return None
41
+ for marker, check in _AUDIT_SCRIPT_REFUSAL_CHECKS.items():
42
+ if marker in error_message:
43
+ return check
44
+ return None
45
+
46
+
47
+ # Error texts of a script that does not fit the server, as Midpoint 4.8.14 reports them: a compile
48
+ # error ("Compilation error ... unable to resolve class X"), an API change ("No signature of
49
+ # method ..."), or a class missing at run time, reported as the bare class name.
50
+ _AUDIT_SCRIPT_COMPILE_MARKERS = ('Compilation error', 'unable to resolve class')
51
+ _AUDIT_SCRIPT_LINKAGE_MARKERS = (
52
+ 'ClassNotFoundException',
53
+ 'NoClassDefFoundError',
54
+ 'No signature of method',
55
+ 'MissingMethodException',
56
+ 'No such property',
57
+ 'MissingPropertyException',
58
+ )
59
+ _AUDIT_SCRIPT_MISSING_CLASS = re.compile(r": (?:[a-z_$][\w$]*[./])+[A-Z][\w$]* in (?:in )?'execute-script'")
60
+ _AUDIT_SCRIPT_INCOMPATIBLE_HELP = (
61
+ 'Audit search failed on {server}: the Groovy script it runs on the server {problem}. The script relies on '
62
+ 'Midpoint-internal classes (SpringApplicationContextHolder, AuditService, PrismContext) that may have moved '
63
+ 'or changed in this version; midpoint-cli supports Midpoint 4.4 to 4.11. Server error: {error}'
64
+ )
65
+
66
+
67
+ def _audit_script_incompatibility(error_message: str) -> str | None:
68
+ """Describe a compile or linkage failure of the audit script, or None for other errors."""
69
+ if any(marker in error_message for marker in _AUDIT_SCRIPT_COMPILE_MARKERS):
70
+ return 'could not be compiled'
71
+ if any(marker in error_message for marker in _AUDIT_SCRIPT_LINKAGE_MARKERS) or (
72
+ _AUDIT_SCRIPT_MISSING_CLASS.search(error_message) is not None
73
+ ):
74
+ return "does not match the server's internal classes"
75
+ return None
76
+
77
+
78
+ def audit_script_error(
79
+ error: MidpointServerError, server_version: MidpointVersion | None
80
+ ) -> MidpointServerError | None:
81
+ """The clearer error to raise instead of error when the server refused or could not run the audit script.
82
+
83
+ Returns MidpointAuditScriptRefused (only for servers that refuse Groovy by default, so the
84
+ errors of earlier versions are never read as a refusal), MidpointAuditScriptIncompatible,
85
+ or None when error is about something else.
86
+ """
87
+ server = describe_version(server_version)
88
+ check = _audit_script_refusal_check(str(error)) if groovy_refused_by_default(server_version) else None
89
+ if check is not None:
90
+ return MidpointAuditScriptRefused(_AUDIT_SCRIPT_REFUSAL_HELP.format(check=check, server=server))
91
+ problem = _audit_script_incompatibility(str(error))
92
+ if problem is not None:
93
+ return MidpointAuditScriptIncompatible(
94
+ _AUDIT_SCRIPT_INCOMPATIBLE_HELP.format(server=server, problem=problem, error=error)
95
+ )
96
+ return None
97
+
98
+
99
+ # How far the audit cursor of a run triggered by the CLI starts before the client's
100
+ # clock, so that a client clock ahead of the server's does not hide the run's first events.
101
+ AUDIT_CURSOR_CLOCK_MARGIN = timedelta(seconds=60)
102
+
103
+
104
+ def as_utc(dt: datetime) -> datetime:
105
+ """Make a datetime aware, reading a naive one as UTC (MidpointTask parses 'Z' timestamps as naive)."""
106
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
107
+
108
+
109
+ def initial_audit_cursor(run_started_at: datetime | None, task_start_time: datetime | None) -> datetime | None:
110
+ """Pick where audit streaming starts for ``task_wait``.
111
+
112
+ Without ``run_started_at`` the cursor is the task's last run start, a server timestamp.
113
+ ``run_started_at`` comes from the client clock, so it is moved back by
114
+ ``AUDIT_CURSOR_CLOCK_MARGIN``. The task's last run start, read after the run was
115
+ triggered, is never later than the events of this run, so it can only narrow that window.
116
+ """
117
+ task_start = as_utc(task_start_time) if task_start_time is not None else None
118
+ if run_started_at is None:
119
+ return task_start
120
+ cursor = as_utc(run_started_at) - AUDIT_CURSOR_CLOCK_MARGIN
121
+ if task_start is not None and task_start > cursor:
122
+ return task_start
123
+ return cursor
124
+
125
+
126
+ def parse_audit_timestamp(text: str) -> datetime | None:
127
+ """Parse an audit-event ISO-8601 timestamp into an aware UTC datetime."""
128
+ iso = text.strip()
129
+ if iso.endswith('Z'):
130
+ iso = iso[:-1] + '+00:00'
131
+ try:
132
+ dt = datetime.fromisoformat(iso)
133
+ except ValueError:
134
+ return None
135
+ if dt.tzinfo is None:
136
+ dt = dt.replace(tzinfo=UTC)
137
+ return dt.astimezone(UTC)
138
+
139
+
140
+ def parse_audit_events_from_script_response(response_xml: str) -> list:
141
+ """Extract MidpointAuditEvent records from an executeScript response.
142
+
143
+ The Groovy script returns a string containing concatenated
144
+ ``<aud:auditEventRecord>`` elements wrapped in a parent. Midpoint
145
+ serializes that as one ``<s:value xsi:type="xsd:string">`` per pipeline
146
+ item; the inner XML arrives HTML-escaped inside the string.
147
+ """
148
+ tree = safe_fromstring(response_xml)
149
+
150
+ status = tree.find(f'{{{COMMON_NS}}}status')
151
+ if status is None:
152
+ # When wrapped in apti:ExecuteScriptResponseType, the result is at apti:result/c:status
153
+ status = tree.find(f'{{{API_TYPES_NS}}}result/{{{COMMON_NS}}}status')
154
+ if status is not None and status.text == ResultStatus.FATAL_ERROR:
155
+ msg = tree.find(f'{{{COMMON_NS}}}message')
156
+ if msg is None:
157
+ msg = tree.find(f'{{{API_TYPES_NS}}}result/{{{COMMON_NS}}}message')
158
+ raise MidpointServerError(msg.text if msg is not None and msg.text else 'audit search failed')
159
+
160
+ events: list = []
161
+ for value_node in tree.iter(f'{{{SCRIPTING_NS}}}value'):
162
+ text = value_node.text
163
+ if not text or '<aud:auditEventRecord' not in text:
164
+ continue
165
+ # ElementTree already decoded character entities — text is real XML.
166
+ # Re-wrap to ensure a single root, in case the embedded namespace
167
+ # prefixes aren't bound on the new root (the Groovy wrapper already
168
+ # produces a single <auditEventRecords> root, but defend against
169
+ # future shape changes).
170
+ wrapped = f'<auditEventRecords xmlns:aud="{AUDIT_NS}" xmlns:c="{COMMON_NS}">{text}</auditEventRecords>'
171
+ try:
172
+ inner = safe_fromstring(wrapped)
173
+ except ElementTree.ParseError as e:
174
+ logger.warning('Skipping malformed audit record payload: %s', e)
175
+ continue
176
+ for record in inner.iter(f'{{{AUDIT_NS}}}auditEventRecord'):
177
+ events.append(MidpointAuditEvent(record))
178
+ return events