Repository: peopledoc/vault-cli

Issue:
Feature request: delete-all command
Same as `get-all` but for recursive deletion:
`vault -U https://myvault.test:8200 -b myapp -w delete-all db/`

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8eea173..9c36d05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ CHANGELOG
 
 - Added vault dump-config
 - Added vault bootstrap-env
+- API break : `client.get_all(paths)` becomes `client.get_all_secrets(*paths)`
+- Added vault delete-all
 
 0.3.9
 -----
diff --git a/README.md b/README.md
index ac7eefd..62094f1 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ Options:
 
 Commands:
   delete       Deletes a single secret.
+  delete-all   Displays all the current settings in the format of a config...
   dump-config  Displays all the current settings in the format of a config...
   env          Launches the given command with all secrets from --path...
   get          Return a single secret value.
@@ -125,6 +126,12 @@ code=supercode
 
 # Recreate a configuration file based on the current settings
 $ vault --url https://something --token mytoken dump-config > .vault.yaml
+
+# Delete everything under blob-secret
+$ vault delete-all blob-secret
+
+# Delete everything, no confirmation
+$ vault delete-all --force
 ```
 
 ## Configuration
diff --git a/vault_cli/cli.py b/vault_cli/cli.py
index 915aea8..8f367bc 100644
--- a/vault_cli/cli.py
+++ b/vault_cli/cli.py
@@ -176,7 +176,7 @@ def get_all(client_obj: client.VaultClientBase, path: Sequence[str]):
     """
     paths = list(path) or [""]
 
-    result = client_obj.get_all(paths)
+    result = client_obj.get_all_secrets(*paths)
 
     click.echo(
         yaml.safe_dump(result, default_flow_style=False, explicit_start=True), nl=False
@@ -278,7 +278,7 @@ def env(
     """
     paths = list(path) or [""]
 
-    secrets = client_obj.get_all(paths, merged=True)
+    secrets = client_obj.get_all(*paths, merged=True)
     secrets_str = {key: str(value) for key, value in secrets.items()}
 
     environ = os.environ.copy()
@@ -316,6 +316,29 @@ def dump_config(client_obj: client.VaultClientBase,) -> None:
     )
 
 
+@cli.command("delete-all")
+@click.option(
+    "-f",
+    "--force",
+    is_flag=True,
+    help="If not force, prompt for confirmation before each deletion.",
+)
+@click.argument("path", required=False, nargs=-1)
+@click.pass_obj
+def delete_all(
+    client_obj: client.VaultClientBase, path: Sequence[str], force: bool
+) -> None:
+    """
+    Displays all the current settings in the format of a config file.
+    """
+    paths = list(path) or [""]
+
+    for secret in client_obj.delete_all_secrets(*paths):
+        if not force and not click.confirm(text=f"Delete '{secret}'?", default=False):
+            raise click.Abort()
+        click.echo(f"Deleted '{secret}'")
+
+
 def main():
     # https://click.palletsprojects.com/en/7.x/python3/
     os.environ.setdefault("LC_ALL", "C.UTF-8")
diff --git a/vault_cli/client.py b/vault_cli/client.py
index d526c91..78a6e03 100644
--- a/vault_cli/client.py
+++ b/vault_cli/client.py
@@ -17,10 +17,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 """
 import json
+import logging
 from typing import Iterable, Optional, Type, Union
 
 from vault_cli import settings, types
 
+logger = logging.getLogger(__name__)
+
 
 def get_client(**kwargs) -> "VaultClientBase":
     """
@@ -65,9 +68,12 @@ def get_client(**kwargs) -> "VaultClientBase":
         Deletes the secret at the given path
     - set_secret(path, value)
         Writes the secret at the given path
-    - get_all(paths=None)
-        Given an iterable of path, recursively returns all
+    - get_all_secrets(paths=None)
+        Given an iterable of paths, recursively returns all
         the secrets
+    - delete_all_secrets(paths=None)
+        Given an iterable of paths, recursively yields then deletes
+        all the secrets under those paths. Use with extreme caution.
     """
     options = settings.get_vault_options(**kwargs)
     backend = options.pop("backend")
@@ -149,45 +155,59 @@ class VaultClientBase:
         else:
             raise ValueError("No authentication method supplied")
 
-    def _get_recursive_secrets(self, path: str) -> types.JSONValue:
-        result: types.JSONDict = {}
-        path = path.rstrip("/")
+    def _browse_recursive_secrets(self, path: str) -> Iterable[str]:
+        """
+        Given a secret or folder path, return the path of all secrets
+        under it (or the path itself)
+        """
+        # 4 things can happen:
+        # - path is "", it's the root (and a folder)
+        # - path ends with /, we know it's a folder
+        # - path doesn't end with a / and yet it's a folder
+        # - path is a secret
+
+        folder = path.endswith("/") or path == ""
+
         sub_secrets = self.list_secrets(path=path)
 
-        if not sub_secrets:
-            return self.get_secret(path=path)
+        if not folder and not sub_secrets:
+            # It's most probably a secret
+            yield path
 
         for key in sub_secrets:
-            key_url = "/".join([path, key]) if path else key
-
-            folder = key_url.endswith("/")
+            folder = key.endswith("/")
             key = key.rstrip("/")
-            if folder:
-                result[key] = self._get_recursive_secrets(key_url)
+            key_url = f"{path}/{key}" if path else key
+            if not folder:
+                yield key_url
                 continue
 
-            secret = self.get_secret(path=key_url)
-            result[key] = secret
-
-        return result
+            for sub_path in self._browse_recursive_secrets(key_url):
+                yield sub_path
 
-    def get_all(self, paths: Iterable[str], merged: bool = False) -> types.JSONDict:
+    def get_all_secrets(self, *paths: str, merged: bool = False) -> types.JSONDict:
         result: types.JSONDict = {}
 
         for path in paths:
-            secrets = self._get_recursive_secrets(path=path)
-            result.update(nested_keys(path, secrets))
-
-        if "" in result:
-            root_val = result.pop("")
-            assert isinstance(root_val, dict)
-            result.update(root_val)
+            secrets_paths = self._browse_recursive_secrets(path=path)
+            for secret_path in secrets_paths:
+                value = self.get_secret(path=secret_path)
+                deep_update(dict_obj=result, path=secret_path, value=value)
 
         if merged:
             result = self._merge_secrets(result)
 
         return result
 
+    def get_all(self, *args, **kwargs):
+        """
+        Synonym to get_all_secrets. Can be removed on 0.6.0.
+        """
+        logger.warning(
+            "Using deprecated 'get_all' method. Use 'get_all_secrets' instead."
+        )
+        return self.get_all_secrets(*args, **kwargs)
+
     def _merge_secrets(self, secrets: types.JSONDict) -> types.JSONDict:
         """
         From a dict containing both individual values and folders of
@@ -217,6 +237,16 @@ class VaultClientBase:
 
         return secrets
 
+    def delete_all_secrets(self, *paths: str) -> Iterable[str]:
+        """
+        Recursively deletes all the secrets at the given paths.
+        """
+        for path in paths:
+            secrets_paths = self._browse_recursive_secrets(path=path)
+            for secret_path in secrets_paths:
+                yield secret_path
+                self.delete_secret(secret_path)
+
     def _init_session(self, url: str, verify: types.VerifyOrCABundle) -> None:
         raise NotImplementedError
 
@@ -242,16 +272,26 @@ class VaultClientBase:
         raise NotImplementedError
 
 
-def nested_keys(path: str, value: types.JSONValue) -> types.JSONDict:
+def deep_update(
+    dict_obj: types.JSONDict, path: str, value: types.JSONValue
+) -> types.JSONDict:
     """
-    >>> nested_path('test', 'foo')
-    {'test': 'foo'}
-
-    >>> nested_path('test/bla', 'foo')
-    {'test': {'bla': 'foo'}}
+    Adds value at the given path, creating necessary levels
+    >>> deep_update(dict_obj={"a": "b"}, path="c", "d")
+    {"a": "b", "c": "d"}
+    >>> deep_update(dict_obj={"a": {"b": "c"}}, path="a/d", "e")
+    {"a": {"b": "c", "d": "e"}}
+    >>> deep_update(dict_obj={"a": {"b": "c"}}, path="a/d/e", "f")
+    {"a": {"b": "c", "d": {"e": "f}}}
     """
-    try:
-        base, subpath = path.strip("/").split("/", 1)
-    except ValueError:
-        return {path: value}
-    return {base: nested_keys(subpath, value)}
+
+    *folders, subpath = path.strip("/").split("/")
+    original_dict_obj = dict_obj
+
+    for folder in folders:
+        sub_obj = dict_obj.setdefault(folder, {})
+        assert isinstance(sub_obj, dict)
+        dict_obj = sub_obj
+
+    dict_obj[subpath] = value
+    return original_dict_obj


Test patch:
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index 026d67a..76834f1 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -14,10 +14,18 @@ def call(cli_runner, *args):
 
 
 def test_integration_cli(cli_runner):
+
     call(cli_runner, ["set", "a", "b"])
 
     assert call(cli_runner, ["get", "a", "--text"]).output == "b\n"
 
+    # Both testing it and using it to clean the vault
+    call(cli_runner, ["delete-all", "--force"])
+
+    assert call(cli_runner, ["list"]).output == "\n"
+
+    call(cli_runner, ["set", "a", "b"])
+
     assert call(cli_runner, ["list"]).output == "a\n"
 
     call(cli_runner, ["set", "c/d", "e"])
@@ -40,7 +48,9 @@ c:
 
     assert call(cli_runner, ["list"]).output == "c/\n"
 
-    call(cli_runner, ["delete", "c/d"])
+    call(cli_runner, ["delete-all", "--force"])
+
+    assert call(cli_runner, ["list"]).output == "\n"
 
 
 def test_integration_lib():
@@ -51,6 +61,12 @@ def test_integration_lib():
 
     assert client.get_secret("a") == "b"
 
+    assert "a" in list(client.delete_all_secrets(""))
+
+    assert client.list_secrets("") == []
+
+    client.set_secret("a", "b")
+
     assert client.list_secrets("") == ["a"]
 
     client.set_secret("c/d", "e")
@@ -61,13 +77,13 @@ def test_integration_lib():
 
     assert client.list_secrets("c") == ["d"]
 
-    assert client.get_all([""]) == {"a": "b", "c": {"d": "e"}}
+    assert client.get_all_secrets("") == {"a": "b", "c": {"d": "e"}}
 
     client.delete_secret("a")
 
     assert client.list_secrets("") == ["c/"]
 
-    client.delete_secret("c/d")
+    assert list(client.delete_all_secrets("")) == ["c/d"]
 
 
 @pytest.fixture
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index cc2c965..bd4b270 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -10,6 +10,7 @@ class FakeClient(client.VaultClientBase):
     def __init__(self, **kwargs):
         self.init_kwargs = kwargs
         print(kwargs)
+        self.deleted = []
 
     def get_secret(self, path):
         return "bar"
@@ -21,7 +22,7 @@ class FakeClient(client.VaultClientBase):
         self.set = [path, value]
 
     def delete_secret(self, path):
-        self.deleted = path
+        self.deleted.append(path)
 
 
 @pytest.fixture
@@ -174,7 +175,7 @@ def test_delete(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["delete", "a"])
 
     assert result.exit_code == 0
-    assert backend.deleted == "a"
+    assert backend.deleted == ["a"]
 
 
 def test_env(cli_runner, backend, mocker):
@@ -304,3 +305,37 @@ def test_dump_config(cli_runner, backend):
     output = yaml.safe_load(result.output)
 
     assert output == expected_settings
+
+
+def test_delete_all(cli_runner, backend):
+
+    result = cli_runner.invoke(cli.cli, ["delete-all"], input="y\ny")
+    assert result.output.splitlines() == [
+        "Delete 'foo'? [y/N]: y",
+        "Deleted 'foo'",
+        "Delete 'baz'? [y/N]: y",
+        "Deleted 'baz'",
+    ]
+    assert backend.deleted == ["foo", "baz"]
+    assert result.exit_code == 0
+
+
+def test_delete_all_cancel(cli_runner, backend):
+
+    result = cli_runner.invoke(cli.cli, ["delete-all"], input="y\nn")
+    assert result.output.splitlines() == [
+        "Delete 'foo'? [y/N]: y",
+        "Deleted 'foo'",
+        "Delete 'baz'? [y/N]: n",
+        "Aborted!",
+    ]
+    assert backend.deleted == ["foo"]
+    assert result.exit_code != 0
+
+
+def test_delete_all_force(cli_runner, backend):
+
+    result = cli_runner.invoke(cli.cli, ["delete-all", "--force"])
+    assert result.output.splitlines() == ["Deleted 'foo'", "Deleted 'baz'"]
+    assert backend.deleted == ["foo", "baz"]
+    assert result.exit_code == 0
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index 2901b70..7c80032 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -79,11 +79,15 @@ def test_vault_client_base_not_implemented(func, args):
 
 
 @pytest.mark.parametrize(
-    "path, value, expected",
-    [("test", "foo", {"test": "foo"}), ("test/bla", "foo", {"test": {"bla": "foo"}})],
+    "dict_obj, path, value, expected",
+    [
+        ({"a": "b"}, "c", "d", {"a": "b", "c": "d"}),
+        ({"a": {"b": "c"}}, "a/d", "e", {"a": {"b": "c", "d": "e"}}),
+        ({"a": {"b": "c"}}, "a/d/e", "f", {"a": {"b": "c", "d": {"e": "f"}}}),
+    ],
 )
-def test_nested_keys(path, value, expected):
-    assert client.nested_keys(path, value) == expected
+def test_deep_update(dict_obj, path, value, expected):
+    assert client.deep_update(dict_obj=dict_obj, path=path, value=value) == expected
 
 
 def test_vault_client_base_call_init_session():
@@ -246,7 +250,7 @@ def test_vault_client_set_ca_bundle_no_verify():
     assert session_kwargs["verify"] is False
 
 
-def test_vault_client_base_get_recursive_secrets():
+def test_vault_client_base_browse_recursive_secrets():
     class TestVaultClient(client.VaultClientBase):
         def __init__(self):
             pass
@@ -257,12 +261,12 @@ def test_vault_client_base_get_recursive_secrets():
         def get_secret(self, path):
             return {"a": "secret-a", "b/c": "secret-bc"}[path]
 
-    result = TestVaultClient()._get_recursive_secrets("")
+    result = list(TestVaultClient()._browse_recursive_secrets(""))
 
-    assert result == {"a": "secret-a", "b": {"c": "secret-bc"}}
+    assert result == ["a", "b/c"]
 
 
-def test_vault_client_base_get_recursive_secrets_single_secret():
+def test_vault_client_base_browse_recursive_secrets_single_secret():
     class TestVaultClient(client.VaultClientBase):
         def __init__(self):
             pass
@@ -270,12 +274,9 @@ def test_vault_client_base_get_recursive_secrets_single_secret():
         def list_secrets(self, path):
             return {"a": []}[path]
 
-        def get_secret(self, path):
-            return "secret-a"
+    result = list(TestVaultClient()._browse_recursive_secrets("a"))
 
-    result = TestVaultClient()._get_recursive_secrets("a")
-
-    assert result == "secret-a"
+    assert result == ["a"]
 
 
 def test_vault_client_base__merge_secrets():
@@ -290,7 +291,7 @@ def test_vault_client_base__merge_secrets():
     }
 
 
-def test_vault_client_base_get_all():
+def test_vault_client_base_get_all_secrets():
     class TestVaultClient(client.VaultClientBase):
         def __init__(self):
             pass
@@ -301,14 +302,41 @@ def test_vault_client_base_get_all():
         def get_secret(self, path):
             return {"a/c": "secret-ac", "b": "secret-b"}[path]
 
-    result = TestVaultClient().get_all(["a", ""])
+    result = TestVaultClient().get_all_secrets("a", "")
 
     assert result == {"a": {"c": "secret-ac"}, "b": "secret-b"}
 
-    result = TestVaultClient().get_all(["a"])
+    result = TestVaultClient().get_all_secrets("a")
 
     assert result == {"a": {"c": "secret-ac"}}
 
-    result = TestVaultClient().get_all(["a", ""], merged=True)
+    result = TestVaultClient().get_all_secrets("a", "", merged=True)
 
     assert result == {"c": "secret-ac", "b": "secret-b"}
+
+
+def test_vault_client_base_delete_all_secrets():
+    deleted = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def delete_secret(self, path):
+            deleted.append(path)
+
+        def list_secrets(self, path):
+            return {"": ["a/", "b/"], "a": ["c"], "b": ["d"]}[path]
+
+    result = TestVaultClient().delete_all_secrets("a", "b")
+    next(result)
+
+    assert deleted == []
+
+    next(result)
+
+    assert deleted == ["a/c"]
+
+    list(result)
+
+    assert deleted == ["a/c", "b/d"]