From dcd96317304b8ddc2b17a649ac465f5cd408491a Mon Sep 17 00:00:00 2001 From: Max Ustinov Date: Tue, 4 Jun 2024 15:39:37 -0700 Subject: [PATCH] Release KSM CLI v1.1.4 (#605) * KSM-507 `ksm cli` add secret delete functionality (#594) * KSM-508 `ksm cli` add secret lookup by title (#595) * KSM-508 Added record lookup by title * KSM-509 Added folder commands (#596) --- .../keeper_secrets_manager_cli/README.md | 6 + .../keeper_secrets_manager_cli/__main__.py | 151 ++++++++++++-- .../keeper_secrets_manager_cli/folder.py | 192 ++++++++++++++++++ .../keeper_secrets_manager_cli/profile.py | 2 +- .../keeper_secrets_manager_cli/secret.py | 35 +++- .../keeper_secrets_manager_cli/setup.py | 2 +- 6 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/folder.py diff --git a/integration/keeper_secrets_manager_cli/README.md b/integration/keeper_secrets_manager_cli/README.md index fa9e3012..f68263b4 100644 --- a/integration/keeper_secrets_manager_cli/README.md +++ b/integration/keeper_secrets_manager_cli/README.md @@ -6,6 +6,12 @@ For more information see our official documentation page https://docs.keeper.io/ # Change History +## 1.1.4 + +- KSM-507: Added `ksm secret delete` command +- KSM-508: Added search by title to `ksm secret list` command +- KSM-509: Added `ksm folder ...` commands + ## 1.1.3 - KSM-496: Added upload file option diff --git a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/__main__.py b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/__main__.py index 56cb8e50..724356eb 100644 --- a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/__main__.py +++ b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/__main__.py @@ -17,6 +17,7 @@ from . import KeeperCli from .exception import KsmCliException from .exec import Exec +from .folder import Folder from .secret import Secret from .sync import Sync from .profile import Profile @@ -51,8 +52,10 @@ class AliasedGroup(HelpColorsGroup): "export", "import", "init", + "default", "setup", "sync", + "folder", "secret", "totp", "download", @@ -61,6 +64,7 @@ class AliasedGroup(HelpColorsGroup): "list", "notation", "update", + "delete", "version", "password", "template", @@ -223,6 +227,20 @@ def base_command_help(f): return f +def validate_non_empty(ctx, param, value): + """Validate that parameter's value is not an empty string""" + if isinstance(value, str) and value != "": + return value + raise click.BadParameter("Empty strings are not allowed") + + +def validate_non_empty_or_blank_list(ctx, param, value): + """Validate parameter's value - list doesn't contain empty strings""" + if isinstance(value, tuple) and next((x for x in value if str(x).strip() == ""), None) is None: + return value + raise click.BadParameter("Empty strings are not allowed") + + class Mutex(click.Option): def __init__(self, *args, **kwargs): # Detect mutually exclusive or required options - search by key only or key and value @@ -514,6 +532,103 @@ def profile_import_command(ctx, profile_name, output_file, config_base64): profile_command.add_command(profile_import_command) +# FOLDER GROUP +@click.group( + name='folder', + cls=AliasedGroup, + help_headers_color='yellow', + help_options_color='green' +) +@click.pass_context +def folder_command(ctx): + """Commands for folders""" + ctx.obj["folder"] = Folder(cli=ctx.obj["cli"]) + + +@click.command( + name='list', + cls=HelpColorsCommand, + help_options_color='blue' +) +@click.option('--folder', '-f', type=str, help='List only records in specified folder UID') +@click.option('--recursive', '-r', is_flag=True, help='List recursively including subfolders of the folder UID') +@click.option('--list-records', '-l', is_flag=True, help='List folder records too') +@click.option('--json', is_flag=True, help='Format result as JSON') +@click.pass_context +def folder_list_command(ctx, folder, recursive, list_records, json): + """List folders""" + + output = "json" if json is True else "text" + ctx.obj["folder"].list_folders( + folder=folder, + recursive=recursive, + list_records=list_records, + output_format=output, + use_color=ctx.obj["cli"].use_color + ) + + +@click.command( + name='add', + cls=HelpColorsCommand, + help_options_color='blue' +) +@click.option('--parent-folder', '-f', type=str, required=True, callback=validate_non_empty, help='Parent folder UID') +@click.option('--title', '-t', type=str, required=True, callback=validate_non_empty, help='New folder title') +@click.pass_context +def folder_add_command(ctx, parent_folder, title): + """Create new subfolder in specified parent folder""" + + ctx.obj["folder"].add_folder( + parent_folder=parent_folder, + title=title + ) + + +@click.command( + name='update', + cls=HelpColorsCommand, + help_options_color='blue' +) +@click.option('--folder', '-f', type=str, required=True, callback=validate_non_empty, help='Folder UID') +@click.option('--title', '-t', type=str, required=True, help='New folder title') +@click.pass_context +def folder_update_command(ctx, folder, title): + """Rename folder""" + + ctx.obj["folder"].update_folder( + folder_uid=folder, + folder_name=title + ) + + +@click.command( + name='delete', + cls=HelpColorsCommand, + help_options_color='blue' +) +@click.option('--force', '-f', is_flag=True, help='Force deletion of non-empty folders') +@click.option('--json', is_flag=True, help='Format result as JSON') +@click.argument('folder_uid', type=str, required=True, nargs=-1, callback=validate_non_empty_or_blank_list) +@click.pass_context +def folder_delete_command(ctx, force, json, folder_uid): + """Delete folders""" + + output = "json" if json is True else "text" + ctx.obj["folder"].delete_folders( + uids=folder_uid, + force=force, + output_format=output, + use_color=ctx.obj["cli"].use_color + ) + + +folder_command.add_command(folder_list_command) +folder_command.add_command(folder_add_command) +folder_command.add_command(folder_update_command) +folder_command.add_command(folder_delete_command) + + # SECRET GROUP @click.group( name='secret', @@ -537,21 +652,20 @@ def secret_command(ctx): @click.option('--recursive', '-r', is_flag=True, help='List recursively all records including subfolders of the folder UID') @click.option('--query', '-q', type=str, help='List records matching the JSONPath query') @click.option('--show-value', '-v', is_flag=True, help='Print matching value instead of record title') +@click.option('--title', '-t', type=str, help='List only records with title matching the regex') @click.option('--json', is_flag=True, help='Return secret as JSON') @click.pass_context -def secret_list_command(ctx, uid, folder, recursive, query, show_value, json): +def secret_list_command(ctx, uid, folder, recursive, query, show_value, title, json): """List all secrets""" - output = "text" - if json is True: - output = "json" - + output = "json" if json is True else "text" ctx.obj["secret"].secret_list( uids=uid, folder=folder, recursive=recursive, query=query, show_value=show_value, + title=title, output_format=output, use_color=ctx.obj["cli"].use_color ) @@ -650,6 +764,20 @@ def secret_update_command(ctx, uid, field, custom_field, field_json, custom_fiel ) +@click.command( + name='delete', + cls=HelpColorsCommand, + help_options_color='blue' +) +@click.option('--uid', '-u', required=True, type=str, callback=validate_non_empty_or_blank_list, multiple=True, help='UIDs of secrets to delete.') +@click.option('--json', is_flag=True, help='Return results as JSON') +@click.pass_context +def secret_delete_command(ctx, uid, json): + """Delete secret records""" + output = "json" if json else "text" + ctx.obj["secret"].delete(uids=uid, output_format=output, use_color=ctx.obj["cli"].use_color) + + @click.command( name='upload', cls=HelpColorsCommand, @@ -742,8 +870,6 @@ def secret_password_command(ctx, length, lc, uc, d, sc): # SECRET TEMPLATE COMMAND - - @click.group( name='template', cls=AliasedGroup, @@ -823,8 +949,6 @@ def secret_template_field_command(ctx, show_list, output_format, version, field_ # SECRET ADD COMMAND - - @click.group( name='add', cls=AliasedGroup, @@ -916,13 +1040,6 @@ def secret_add_field_command(ctx, storage_folder_uid, record_type, title, passwo print("", file=sys.stderr) -def validate_non_empty(ctx, param, value): - """Validate that parameter's value is not an empty string""" - if isinstance(value, str) and value != "": - return value - raise click.BadParameter("Empty strings are not allowed") - - @click.command( name='clone', cls=HelpColorsCommand, @@ -951,6 +1068,7 @@ def secret_add_clone_command(ctx, uid, title): secret_command.add_command(secret_get_command) secret_command.add_command(secret_notation_command) secret_command.add_command(secret_update_command) +secret_command.add_command(secret_delete_command) secret_command.add_command(secret_add_command) secret_command.add_command(secret_upload_command) secret_command.add_command(secret_download_command) @@ -1242,6 +1360,7 @@ def sync_command(ctx, credentials, type, dry_run, preserve_missing, map): # TOP LEVEL COMMANDS cli.add_command(profile_command) cli.add_command(sync_command) +cli.add_command(folder_command) cli.add_command(secret_command) cli.add_command(exec_command) cli.add_command(config_command) diff --git a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/folder.py b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/folder.py new file mode 100644 index 00000000..eb6fbca5 --- /dev/null +++ b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/folder.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# _ __ +# | |/ /___ ___ _ __ ___ _ _ (R) +# | ' Tuple[CreateOptions, List[KeeperFolder]]: + """ Build and return folder create options and folders list """ + + # find closest shared folder parent + if not folders: + folders = self.cli.client.get_folders() or [] + + shared_folder = next((x for x in folders if x.folder_uid == folder_uid), None) + while shared_folder and shared_folder.parent_uid: + shared_folder = next((x for x in folders if x.folder_uid == shared_folder.parent_uid), shared_folder) + + if shared_folder is None: + raise KsmCliException(f'Unable to find the shared folder for {folder_uid}') + if not shared_folder.folder_key: + raise KsmCliException(f'Unable to find folder key for folder {shared_folder.folder_uid}') + + # create folder options + create_options = CreateOptions(shared_folder.folder_uid, folder_uid) + return create_options, folders diff --git a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/profile.py b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/profile.py index d4d3bbf2..07f25a50 100644 --- a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/profile.py +++ b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/profile.py @@ -134,7 +134,7 @@ def init(token, ini_file=None, server=None, profile_name=None, launched_from_app # Get the secret records to get the app key. The SDK will add the app key to the config. try: - client.get_secrets() + client.get_secrets(["AAAAAAAAAAAAAAAAAAAAAA"]) except (KeeperError, KeeperAccessDenied) as err: # If we just create the INI file and there was an error. Remove it. if created_ini is True: diff --git a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/secret.py b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/secret.py index e6525ea2..e4b222b3 100644 --- a/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/secret.py +++ b/integration/keeper_secrets_manager_cli/keeper_secrets_manager_cli/secret.py @@ -472,13 +472,19 @@ def _format_list(record_dict, use_color=True, columns=None): table.add_row([record["uid"], record["type"], record["title"]]) return "\n" + table.get_string() + "\n" - def secret_list(self, uids=None, folder=None, recursive=False, query=None, show_value=False, output_format='json', use_color=None): + def secret_list(self, uids=None, folder=None, recursive=False, query=None, show_value=False, title="", output_format='json', use_color=None): if use_color is None: - use_color = self.cli.user_color + use_color = self.cli.use_color loadrefs = True if query else False # to load fields[] and custom[] record_dict = self.query(uids=uids, folder=folder, recursive=recursive, output_format='dict', load_references=loadrefs, unmask=True, use_color=use_color) + if title: + try: + filtered = [x for x in record_dict if re.search(title, x.get("title", ""), re.IGNORECASE)] + record_dict = filtered + except Exception as err: + raise KsmCliException(f"Check your regex '{title}' - error: {str(err)}") if query: items = self._query_jsonpath_list(query, record_dict) if output_format == 'text': @@ -680,6 +686,31 @@ def _get_label(x): except Exception as err: raise KsmCliException("Could not save record: {}".format(err)) + def delete(self, uids: List[str] = [], output_format: str = "text", use_color=None): + try: + resp = self.cli.client.delete_secret(record_uids=uids) + output = [{"uid": x.get("recordUid", ""), + "responseCode": x.get("responseCode", ""), + "error": x.get("errorMessage", "")} + for x in resp if x.get("recordUid", "") in uids] + output.extend([{"uid": u, "responseCode": "n/a", "error": "Not found"} + for u in uids + if next((r for r in resp if r.get("recordUid") == u), None) is None]) + if output_format == 'json': + self.cli.output(json.dumps(output, indent=4)) + else: # output_format == 'text' + if use_color is None: + use_color = self.cli.use_color + table = Table(use_color=use_color) + table.add_column("UID", data_color=Fore.GREEN) + table.add_column("Response Code", data_color=Fore.YELLOW) + table.add_column("Error", data_color=Fore.RED, allow_wrap=True) + for x in output: + table.add_row([x["uid"], x["responseCode"], x["error"]]) + self.cli.output(f"\n{table.get_string()}\n") + except Exception as err: + raise KsmCliException(f"Could not delete records: {err}") + def _check_if_can_add_records(self): # Check to see if appOwnerPublicKey is in the keeper.ini. It's a newly added key and if the # profile is too old we can't add a record. diff --git a/integration/keeper_secrets_manager_cli/setup.py b/integration/keeper_secrets_manager_cli/setup.py index e353acb0..9f7806e2 100644 --- a/integration/keeper_secrets_manager_cli/setup.py +++ b/integration/keeper_secrets_manager_cli/setup.py @@ -27,7 +27,7 @@ # Version set in the keeper_secrets_manager_cli.version file. setup( name="keeper-secrets-manager-cli", - version="1.1.3", + version="1.1.4", description="Command line tool for Keeper Secrets Manager", long_description=long_description, long_description_content_type="text/markdown",