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.
- midpoint_cli/__init__.py +234 -0
- midpoint_cli/client/__init__.py +199 -0
- midpoint_cli/client/audit.py +178 -0
- midpoint_cli/client/audit_query.py +247 -0
- midpoint_cli/client/configuration.py +15 -0
- midpoint_cli/client/constants.py +37 -0
- midpoint_cli/client/errors.py +74 -0
- midpoint_cli/client/logging_config.py +122 -0
- midpoint_cli/client/midpoint_client.py +866 -0
- midpoint_cli/client/object_query.py +230 -0
- midpoint_cli/client/objects.py +814 -0
- midpoint_cli/client/observer.py +29 -0
- midpoint_cli/client/patch.py +113 -0
- midpoint_cli/client/progress.py +80 -0
- midpoint_cli/client/rest.py +490 -0
- midpoint_cli/client/safe_xml.py +24 -0
- midpoint_cli/client/server_info.py +130 -0
- midpoint_cli/client/server_version.py +74 -0
- midpoint_cli/client/session.py +142 -0
- midpoint_cli/client/task_definitions.py +110 -0
- midpoint_cli/prompt/__init__.py +196 -0
- midpoint_cli/prompt/audit.py +253 -0
- midpoint_cli/prompt/base.py +218 -0
- midpoint_cli/prompt/complete.py +291 -0
- midpoint_cli/prompt/configuration.py +258 -0
- midpoint_cli/prompt/connector.py +45 -0
- midpoint_cli/prompt/console.py +818 -0
- midpoint_cli/prompt/delete.py +89 -0
- midpoint_cli/prompt/get.py +66 -0
- midpoint_cli/prompt/info.py +53 -0
- midpoint_cli/prompt/log.py +98 -0
- midpoint_cli/prompt/org.py +122 -0
- midpoint_cli/prompt/output.py +33 -0
- midpoint_cli/prompt/put.py +71 -0
- midpoint_cli/prompt/report.py +69 -0
- midpoint_cli/prompt/resource.py +217 -0
- midpoint_cli/prompt/role.py +96 -0
- midpoint_cli/prompt/script.py +154 -0
- midpoint_cli/prompt/search.py +51 -0
- midpoint_cli/prompt/shadow.py +87 -0
- midpoint_cli/prompt/task.py +296 -0
- midpoint_cli/prompt/user.py +406 -0
- midpoint_cli-1.6.0.dev3.dist-info/METADATA +1334 -0
- midpoint_cli-1.6.0.dev3.dist-info/RECORD +47 -0
- midpoint_cli-1.6.0.dev3.dist-info/WHEEL +4 -0
- midpoint_cli-1.6.0.dev3.dist-info/entry_points.txt +3 -0
- midpoint_cli-1.6.0.dev3.dist-info/licenses/LICENSE +21 -0
midpoint_cli/__init__.py
ADDED
|
@@ -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
|