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,253 @@
1
+ """`audit` command — query Midpoint's audit log to track all server-side changes."""
2
+
3
+ import re
4
+ import shlex
5
+ from argparse import ArgumentParser, RawTextHelpFormatter
6
+ from typing import cast
7
+ from xml.etree import ElementTree
8
+ from xml.etree.ElementTree import Element
9
+
10
+ from rich.text import Text
11
+ from rich.tree import Tree
12
+
13
+ from midpoint_cli.client import (
14
+ MidpointAuditEvent,
15
+ MidpointObjectList,
16
+ MidpointObjectTypes,
17
+ MidpointServerError,
18
+ namespaces,
19
+ )
20
+ from midpoint_cli.client.audit_query import AuditQuery, parse_duration
21
+ from midpoint_cli.prompt.base import PromptBase
22
+ from midpoint_cli.prompt.complete import USER, completes
23
+ from midpoint_cli.prompt.console import OUTCOME_STYLE, get_console, is_machine_output, print_rows
24
+
25
+ # OIDs are UUIDs; anything matching this pattern is treated as a literal OID,
26
+ # everything else is treated as a name to resolve via the users endpoint.
27
+ _OID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
28
+
29
+
30
+ audit_parser = ArgumentParser(
31
+ formatter_class=RawTextHelpFormatter,
32
+ prog='audit',
33
+ description='Query the Midpoint audit log.',
34
+ epilog="""
35
+ Available commands:
36
+ ls List recent audit events (with filters).
37
+ object Show change history for a single object.
38
+ get Show full details (including deltas) for one event.
39
+ """,
40
+ )
41
+ audit_parser.add_argument('command', help='Audit subcommand to execute.')
42
+ audit_parser.add_argument('arg', help='Subcommand arguments.', nargs='*')
43
+
44
+ audit_ls_parser = ArgumentParser(
45
+ formatter_class=RawTextHelpFormatter,
46
+ prog='audit ls',
47
+ description='List recent audit events. All filters are ANDed together.',
48
+ )
49
+ audit_ls_parser.add_argument('--since', help='Earliest timestamp, e.g. 1h, 30m, 2d, 1w, or ISO-8601.')
50
+ audit_ls_parser.add_argument('--until', help='Latest timestamp, same syntax as --since.')
51
+ completes(audit_ls_parser.add_argument('--user', help='Initiator OID or name.'), USER)
52
+ audit_ls_parser.add_argument('--type', dest='event_type', help='Audit event type, e.g. modifyObject, addObject.')
53
+ audit_ls_parser.add_argument('--stage', help='Audit event stage, e.g. request, execution.')
54
+ audit_ls_parser.add_argument('--channel', help='Channel filter (substring of channel URI fragment).')
55
+ audit_ls_parser.add_argument('--outcome', help='Outcome filter, e.g. success, fatalError.')
56
+ audit_ls_parser.add_argument('--limit', type=int, default=50, help='Max events to return (0 = no limit).')
57
+ audit_ls_parser.add_argument('--show-deltas', action='store_true', help='Render full deltas under each row.')
58
+
59
+ audit_object_parser = ArgumentParser(
60
+ formatter_class=RawTextHelpFormatter,
61
+ prog='audit object',
62
+ description='Show audit events targeting a single object.',
63
+ )
64
+ audit_object_parser.add_argument('oid', help='Target object OID.')
65
+ audit_object_parser.add_argument('--limit', type=int, default=50, help='Max events to return (0 = no limit).')
66
+ audit_object_parser.add_argument('--show-deltas', action='store_true', help='Render full deltas under each row.')
67
+
68
+ audit_get_parser = ArgumentParser(
69
+ formatter_class=RawTextHelpFormatter,
70
+ prog='audit get',
71
+ description='Show full details and deltas for one audit event by event identifier.',
72
+ )
73
+ audit_get_parser.add_argument(
74
+ 'event_identifier', help='Value of the audit eventIdentifier field: the EventIdentifier column of audit ls.'
75
+ )
76
+
77
+ audit_subparsers: dict[str, ArgumentParser | None] = {
78
+ 'ls': audit_ls_parser,
79
+ 'object': audit_object_parser,
80
+ 'get': audit_get_parser,
81
+ }
82
+
83
+
84
+ _EVENT_COLUMN_STYLES = {
85
+ 'Timestamp': 'white',
86
+ 'Stage': 'cyan',
87
+ 'Type': 'bright_cyan',
88
+ 'Initiator': 'blue',
89
+ 'Channel': 'white',
90
+ 'Target': 'blue',
91
+ 'Outcome': 'white',
92
+ # What `audit get` takes. Last, so the columns before it keep their positions.
93
+ 'EventIdentifier': 'blue',
94
+ }
95
+
96
+
97
+ def _event_cell_style(column: str, value: str) -> str | None:
98
+ if column == 'Outcome':
99
+ return OUTCOME_STYLE.get(value.replace('-', '_').lower(), 'white')
100
+ return _EVENT_COLUMN_STYLES.get(column)
101
+
102
+
103
+ class AuditClientPrompt(PromptBase):
104
+ subparsers_audit = audit_subparsers
105
+
106
+ def do_audit(self, inp: str) -> None:
107
+ """Query the Midpoint audit log (subcommands: ls, object, get)."""
108
+ args = shlex.split(inp)
109
+ if not args or args[0] in ('-h', '--help'):
110
+ audit_parser.print_help()
111
+ return
112
+ self._dispatch_subcommand(
113
+ inp,
114
+ None,
115
+ audit_subparsers,
116
+ {'ls': self._audit_ls, 'object': self._audit_object, 'get': self._audit_get},
117
+ errors=(MidpointServerError, ValueError),
118
+ )
119
+
120
+ @staticmethod
121
+ def help_audit() -> None:
122
+ audit_parser.print_help()
123
+
124
+ def _audit_ls(self, argv: list[str]) -> None:
125
+ ns = audit_ls_parser.parse_args(argv)
126
+ query = AuditQuery(limit=ns.limit)
127
+ if ns.since:
128
+ query.since = parse_duration(ns.since)
129
+ if ns.until:
130
+ query.until = parse_duration(ns.until)
131
+ if ns.user:
132
+ query.initiator_oid = self._resolve_user_oid(ns.user)
133
+ if ns.event_type:
134
+ query.event_type = ns.event_type
135
+ if ns.stage:
136
+ query.event_stage = ns.stage
137
+ if ns.channel:
138
+ query.channel = ns.channel
139
+ if ns.outcome:
140
+ query.outcome = ns.outcome
141
+
142
+ events = self.client.search_audit(query)
143
+ # Server returns newest-first so --limit keeps the most recent N;
144
+ # reverse for display so the most recent ends up at the bottom.
145
+ events.reverse()
146
+ self._print_events(events, show_deltas=ns.show_deltas)
147
+
148
+ def _audit_object(self, argv: list[str]) -> None:
149
+ ns = audit_object_parser.parse_args(argv)
150
+ query = AuditQuery(limit=ns.limit, target_oid=ns.oid)
151
+ events = self.client.search_audit(query)
152
+ events.reverse()
153
+ self._print_events(events, title=f'Audit history for {ns.oid}', show_deltas=ns.show_deltas)
154
+
155
+ def _audit_get(self, argv: list[str]) -> None:
156
+ ns = audit_get_parser.parse_args(argv)
157
+ query = AuditQuery(limit=0, event_identifier=ns.event_identifier)
158
+ events = self.client.search_audit(query)
159
+ if not events:
160
+ self._fail(f'No audit event found with identifier {ns.event_identifier}')
161
+ return
162
+ self._print_events(events, title=f'Audit event {ns.event_identifier}', show_deltas=True)
163
+
164
+ def _resolve_user_oid(self, ref: str) -> str:
165
+ """Resolve a CLI --user value (OID or name) to a user OID."""
166
+ if _OID_RE.match(ref):
167
+ return ref
168
+ match = self.client.find_object(MidpointObjectTypes.USER.value, ref)
169
+ if match is None:
170
+ raise ValueError(f'User not found: {ref}')
171
+ oid = match.get_oid()
172
+ if oid is None:
173
+ raise AssertionError
174
+ return oid
175
+
176
+ @classmethod
177
+ def _print_events(cls, events: MidpointObjectList, title: str = 'Audit events', show_deltas: bool = False) -> None:
178
+ """Print audit events in the selected output format.
179
+
180
+ With deltas, a table is followed by one tree of deltas per event, while the machine
181
+ formats carry each event's deltas as XML in a Deltas column.
182
+ """
183
+ rows = []
184
+ for event in events:
185
+ ev = cast(MidpointAuditEvent, event)
186
+ row = {
187
+ 'Timestamp': ev.get('Timestamp'),
188
+ 'Stage': ev.get('Stage'),
189
+ 'Type': ev.get('EventType'),
190
+ 'Initiator': ev.get('Initiator'),
191
+ 'Channel': ev.get('Channel'),
192
+ 'Target': ev.get('Target'),
193
+ 'Outcome': ev.get('Outcome'),
194
+ 'EventIdentifier': ev.get('EventIdentifier'),
195
+ }
196
+ if show_deltas and is_machine_output():
197
+ row['Deltas'] = '\n'.join(
198
+ ElementTree.tostring(delta, encoding='unicode').strip() for delta in ev.get_deltas()
199
+ )
200
+ rows.append(row)
201
+
202
+ columns = list(_EVENT_COLUMN_STYLES)
203
+ if show_deltas and is_machine_output():
204
+ columns.append('Deltas')
205
+ print_rows(rows, title=title, noun='audit events', columns=columns, cell_style=_event_cell_style)
206
+ if show_deltas and not is_machine_output():
207
+ cls._print_event_deltas(events)
208
+
209
+ @classmethod
210
+ def _print_event_deltas(cls, events: MidpointObjectList) -> None:
211
+ console = get_console()
212
+ for event in events:
213
+ ev = cast(MidpointAuditEvent, event)
214
+ deltas = ev.get_deltas()
215
+ if not deltas:
216
+ continue
217
+ label = ev.get('EventIdentifier') or ev.get('Timestamp') or ''
218
+ tree = Tree(Text(f'Deltas for {label}', style='bold'))
219
+ for delta_node in deltas:
220
+ cls._render_delta(tree, delta_node)
221
+ console.print(tree)
222
+
223
+ @staticmethod
224
+ def _render_delta(parent: Tree, delta_node: Element) -> None:
225
+ object_delta = delta_node.find('c:objectDelta', namespaces)
226
+ if object_delta is None:
227
+ # Fall back to the delta node itself if structure is different.
228
+ object_delta = delta_node
229
+
230
+ change_type_el = object_delta.find('c:changeType', namespaces)
231
+ oid_el = object_delta.find('c:oid', namespaces)
232
+ type_el = object_delta.find('c:objectType', namespaces)
233
+
234
+ change_type = change_type_el.text if change_type_el is not None else 'modify'
235
+ target_oid = oid_el.text if oid_el is not None else ''
236
+ target_type = type_el.text.split('}')[-1] if type_el is not None and type_el.text else ''
237
+ header = Text.assemble((change_type or '', 'yellow'), f' {target_type} {target_oid}'.rstrip())
238
+ delta_branch = parent.add(header)
239
+
240
+ for item_delta in object_delta.findall('c:itemDelta', namespaces):
241
+ mod_type = item_delta.find('c:modificationType', namespaces)
242
+ path_el = item_delta.find('c:path', namespaces)
243
+ mod = mod_type.text if mod_type is not None else '?'
244
+ path_text: str | None
245
+ path_text = path_el.text if path_el is not None else None
246
+ label = Text.assemble((mod or '', 'cyan'), ' ', (path_text or '', 'bright_cyan'))
247
+ item_branch = delta_branch.add(label)
248
+ for value in item_delta.findall('c:value', namespaces):
249
+ rendered = ElementTree.tostring(value, encoding='unicode').strip()
250
+ # Truncate very long values for readability.
251
+ if len(rendered) > 400:
252
+ rendered = rendered[:397] + '...'
253
+ item_branch.add(Text(rendered))
@@ -0,0 +1,218 @@
1
+ import contextlib
2
+ import shlex
3
+ from argparse import ArgumentParser, ArgumentTypeError
4
+ from collections.abc import Callable, Mapping
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from midpoint_cli.client import MidpointAmbiguousName, MidpointServerError, TaskExecutionFailure
8
+ from midpoint_cli.client.objects import MidpointObject, MidpointObjectType, MidpointObjectTypes, MidpointTask
9
+ from midpoint_cli.prompt.console import (
10
+ create_progress_monitor,
11
+ create_table,
12
+ get_console,
13
+ is_machine_output,
14
+ print_details,
15
+ print_error,
16
+ print_info,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from midpoint_cli.client import MidpointClient
21
+ from midpoint_cli.prompt.configuration import ResolvedConfiguration
22
+
23
+
24
+ def _positive_int(text: str) -> int:
25
+ try:
26
+ value = int(text)
27
+ except ValueError:
28
+ raise ArgumentTypeError(f'invalid positive integer: {text!r}') from None
29
+ if value < 1:
30
+ raise ArgumentTypeError(f'must be at least 1, got {value}')
31
+ return value
32
+
33
+
34
+ def add_listing_arguments(parser: ArgumentParser, what: str) -> ArgumentParser:
35
+ """Add the server-side --limit and --filter options shared by the ls subcommands."""
36
+ parser.add_argument(
37
+ '--limit',
38
+ type=_positive_int,
39
+ metavar='N',
40
+ help=f'List at most N {what}, sorted by name (paged on the server).',
41
+ )
42
+ parser.add_argument(
43
+ '--filter',
44
+ metavar='TEXT',
45
+ help=f'Only list {what} whose name contains TEXT, ignoring case (filtered on the server).',
46
+ )
47
+ return parser
48
+
49
+
50
+ def add_new_task_arguments(parser: ArgumentParser) -> ArgumentParser:
51
+ """Add the --no-wait and --no-changes options of the commands that create and start a task."""
52
+ parser.add_argument(
53
+ '--no-wait',
54
+ action='store_true',
55
+ help='Start the task and print its OID instead of waiting for it to finish.',
56
+ )
57
+ parser.add_argument(
58
+ '--no-changes',
59
+ action='store_true',
60
+ help="Don't stream audit events while the task runs (silences the inline change log).",
61
+ )
62
+ return parser
63
+
64
+
65
+ # A subcommand handler: called with the arguments that follow the subcommand.
66
+ SubcommandHandler = Callable[[list[str]], None]
67
+
68
+
69
+ def print_object_detail(obj: Mapping[str, Any], title: str) -> None:
70
+ """Print one object's properties in the selected output format (role get, org get).
71
+
72
+ The table lists the non-empty properties; the machine formats every one, empty ones
73
+ included, so scripts see the same keys for every object.
74
+ """
75
+ if is_machine_output():
76
+ print_details([obj], as_list=False)
77
+ return
78
+ table = create_table(title=title)
79
+ table.add_column('Attribute', style='cyan')
80
+ table.add_column('Value', style='white')
81
+ for attr, value in obj.items():
82
+ if value is not None and str(value).strip():
83
+ table.add_row(str(attr), str(value))
84
+ get_console().print(table)
85
+
86
+
87
+ class PromptBase:
88
+ """Base of the command mixins.
89
+
90
+ Besides cmd.Cmd's ``do_<command>``/``help_<command>`` methods, a mixin exposes its parsers
91
+ as class attributes, which the tab completer reads:
92
+
93
+ * ``parser_<command>``: the parser of a command without subcommands;
94
+ * ``subparsers_<command>``: a mapping of each subcommand to its parser, or to None when it
95
+ takes no arguments. The command uses the same mapping for dispatch and ``-h``.
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ # Note: self.client gets set immediately in MidpointClientPrompt.__init__()
100
+ # This is just a type declaration - the actual assignment happens in the subclass
101
+ self.client: MidpointClient
102
+ # Effective settings and their sources, when started from main(); shown by `info`.
103
+ self.configuration: ResolvedConfiguration | None = None
104
+ self.error_code: int | None = None
105
+ self.error_message: str | None = None
106
+
107
+ def _fail(self, message: str | None = None) -> None:
108
+ """Mark the command as failed (exit code 1); onecmd prints the message, if any, as its error."""
109
+ self.error_code = 1
110
+ if message is not None:
111
+ self.error_message = message
112
+
113
+ @staticmethod
114
+ def _subcommand_help(args: list[str], subparsers: Mapping[str, ArgumentParser | None]) -> bool:
115
+ """If -h/--help follows a known subcommand, print that subcommand's help and return True."""
116
+ if args and subparsers.get(args[0]) is not None and any(a in ('-h', '--help') for a in args[1:]):
117
+ subparser = subparsers[args[0]]
118
+ if subparser is None:
119
+ raise AssertionError
120
+ subparser.print_help()
121
+ return True
122
+ return False
123
+
124
+ def _dispatch_subcommand(
125
+ self,
126
+ inp: str,
127
+ parser: ArgumentParser | None,
128
+ subparsers: Mapping[str, ArgumentParser | None],
129
+ handlers: Mapping[str, SubcommandHandler],
130
+ *,
131
+ errors: tuple[type[Exception], ...] = (MidpointServerError,),
132
+ strict: bool = False,
133
+ ) -> None:
134
+ """Run the handler of the subcommand that inp starts with, on the arguments after it.
135
+
136
+ -h after a known subcommand prints that subcommand's help. parser, a command parser
137
+ with ``command`` and ``arg`` positionals, prints the command's help for -h and reports
138
+ a missing subcommand as a usage error; without one, the first word is the subcommand.
139
+ An unknown subcommand fails with ``Unknown command <name>``; with strict, arguments
140
+ after it are a usage error instead. An exception of one of errors raised by the
141
+ handler fails the command with its message.
142
+ """
143
+ try:
144
+ args = shlex.split(inp)
145
+ if self._subcommand_help(args, subparsers):
146
+ return
147
+ if parser is None:
148
+ command, extra = (args[0], args[1:]) if args else ('', [])
149
+ else:
150
+ # Each subcommand parses its own options: they pass through here.
151
+ ns, unknown = parser.parse_known_args(args)
152
+ command, extra = ns.command, ns.arg + unknown
153
+ handler = handlers.get(command)
154
+ if handler is None:
155
+ if strict and extra and parser is not None:
156
+ parser.error(f'unrecognized arguments: {" ".join(extra)}')
157
+ self._fail(f'Unknown command {command}')
158
+ return
159
+ handler(args[1:])
160
+ except errors as e:
161
+ self._fail(str(e))
162
+
163
+ def _list_shortcut(self, inp: str, shortcut: str, command: str, run: Callable[[str], object]) -> None:
164
+ """A no-argument shortcut for `<command> ls` (users, roles, ...): run('ls'), or fail on arguments."""
165
+ if inp.strip():
166
+ self._fail(f'"{shortcut}" takes no arguments. Did you mean "{command} {inp.strip()}"?')
167
+ return
168
+ run('ls')
169
+
170
+ def _find_reference(self, object_type: MidpointObjectType, reference: str, label: str) -> MidpointObject | None:
171
+ """The object a CLI reference (OID or name) designates, or None after printing why there is none.
172
+
173
+ Used by commands that loop over several references, so one bad reference (not found, or a
174
+ name shared by several objects) is reported without stopping the others; the command
175
+ still fails (exit code 1).
176
+ """
177
+ try:
178
+ found = self.client.find_object(object_type, reference)
179
+ except MidpointAmbiguousName as e:
180
+ self._fail()
181
+ print_error(str(e))
182
+ return None
183
+ if found is None:
184
+ self._fail()
185
+ print_error(f'{label} reference not found: {reference}')
186
+ return found
187
+
188
+ def _require_object(self, object_type: MidpointObjectType, reference: str, label: str) -> MidpointObject | None:
189
+ """The object a CLI reference (OID or name) designates, or None after failing with `<label> not found`.
190
+
191
+ A name shared by several objects raises MidpointAmbiguousName, a MidpointServerError.
192
+ """
193
+ found = self.client.find_object(object_type, reference)
194
+ if found is None:
195
+ self._fail(f'{label} not found: {reference}')
196
+ return found
197
+
198
+ def _follow_new_task(self, task_oid: str, task_name: str, no_wait: bool, show_changes: bool) -> None:
199
+ """Report a task the command created, then follow it to completion like `task run`.
200
+
201
+ With no_wait, the task OID is printed on standard output, so a script can capture it.
202
+ """
203
+ print_info(f"Task '{task_name}' started: {task_oid}")
204
+ if no_wait:
205
+ print(task_oid)
206
+ return
207
+ try:
208
+ task = self.client.run_new_task(task_oid, create_progress_monitor(), show_changes=show_changes)
209
+ get_console().print(task.get_full_description())
210
+ except TaskExecutionFailure as e:
211
+ # The task as last read while waiting, with its statistics; fetched when not known.
212
+ failed: MidpointObject | None = e.task
213
+ if failed is None:
214
+ with contextlib.suppress(MidpointServerError): # The failure itself is reported below.
215
+ failed = self.client.get_object(MidpointObjectTypes.TASK.value, task_oid)
216
+ if isinstance(failed, MidpointTask):
217
+ get_console().print(failed.get_full_description())
218
+ self._fail(e.message)