From 374aa1e77b7230f31bd0948487f7c0f418ada5a4 Mon Sep 17 00:00:00 2001 From: Jean-Yves NOLEN Date: Fri, 20 Jan 2023 14:52:05 +0100 Subject: [PATCH 1/4] Add support of namespaces --- vault_cli/client.py | 8 +++++++- vault_cli/settings.py | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/vault_cli/client.py b/vault_cli/client.py index 5713087..af35f89 100644 --- a/vault_cli/client.py +++ b/vault_cli/client.py @@ -78,6 +78,7 @@ def __init__( username: Optional[str] = settings.DEFAULTS.username, password: Optional[str] = settings.DEFAULTS.password, safe_write: bool = settings.DEFAULTS.safe_write, + namespace: Optional[str] = settings.DEFAULTS.namespace, ): self.url = url self.verify: types.VerifyOrCABundle = verify @@ -89,8 +90,10 @@ def __init__( self.username = username self.password = password self.safe_write = safe_write + self.namespace = namespace self.cache: Dict[str, types.JSONDict] = {} self.errors: List[str] = [] + @property def base_path(self): @@ -114,6 +117,7 @@ def auth(self): verify=verify_ca_bundle, login_cert=self.login_cert, login_cert_key=self.login_cert_key, + namespace=self.namespace, ) if self.token: @@ -655,6 +659,7 @@ def _init_client( verify: types.VerifyOrCABundle, login_cert: Optional[str], login_cert_key: Optional[str], + namespace: Optional[str], ) -> None: raise NotImplementedError @@ -716,6 +721,7 @@ def _init_client( verify: types.VerifyOrCABundle, login_cert: Optional[str], login_cert_key: Optional[str], + namespace: Optional[str], ) -> None: self.session = sessions.Session() self.session.verify = verify @@ -725,7 +731,7 @@ def _init_client( cert = (login_cert, login_cert_key) self.client = hvac.Client( - url=url, verify=verify, session=self.session, cert=cert + url=url, verify=verify, session=self.session, cert=cert, namespace=namespace ) def _authenticate_token(self, token: str) -> None: diff --git a/vault_cli/settings.py b/vault_cli/settings.py index 8119a66..440c607 100644 --- a/vault_cli/settings.py +++ b/vault_cli/settings.py @@ -27,7 +27,8 @@ class DEFAULTS: verify = True ca_bundle = None safe_write = False - + namespace = None + @staticmethod def _as_dict(): return {k: v for k, v in vars(DEFAULTS).items() if k[0] != "_"} From 8a5c6fe117a9660f787250918bd0cfcce39376a1 Mon Sep 17 00:00:00 2001 From: Jean-Yves NOLEN Date: Fri, 20 Jan 2023 15:14:41 +0100 Subject: [PATCH 2/4] Missing parameters for namespace in click cli --- vault_cli/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vault_cli/cli.py b/vault_cli/cli.py index 6cc756a..3cea828 100644 --- a/vault_cli/cli.py +++ b/vault_cli/cli.py @@ -143,6 +143,9 @@ def repr_octal(value: Optional[int]) -> Optional[str]: help="Set umask for newly created files. Defaults to files with read-write " "for owner and nothing for group & others", ) +@click.option( + "--namespace", help="Namespace of the vault instance", default=settings.DEFAULTS.namespace +) @click.option( "-v", "--verbose", From 5147fab400901e72385f445df299969165a68a34 Mon Sep 17 00:00:00 2001 From: Jean-Yves NOLEN Date: Fri, 20 Jan 2023 23:21:27 +0100 Subject: [PATCH 3/4] First iteration of KvV1 --- .env | 1 + .gitignore | 1 + tests/integration/test_integration.py | 8 +- tests/unit/test_cli.py | 4 + tests/unit/test_client_base.py | 1 + tests/unit/test_client_hvac.py | 192 ------------------------- tests/unit/test_client_hvac_v1.py | 199 ++++++++++++++++++++++++++ tests/unit/test_client_hvac_v2.py | 199 ++++++++++++++++++++++++++ vault_cli/__init__.py | 12 +- vault_cli/client.py | 113 ++++++++++----- vault_cli/test.py | 30 ++++ vault_cli/testing.py | 5 + vault_cli/types.py | 11 ++ vault_cli/utils.py | 4 + 14 files changed, 542 insertions(+), 238 deletions(-) create mode 100644 .env delete mode 100644 tests/unit/test_client_hvac.py create mode 100644 tests/unit/test_client_hvac_v1.py create mode 100644 tests/unit/test_client_hvac_v2.py create mode 100644 vault_cli/test.py diff --git a/.env b/.env new file mode 100644 index 0000000..4521c63 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PYTEST_ADDOPTS=--no-cov \ No newline at end of file diff --git a/.gitignore b/.gitignore index 04b71cb..39466d6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ vault.yml server-chain.crt client.crt client.key +.vscode/ \ No newline at end of file diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index db7e59b..c36bdd0 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -162,9 +162,9 @@ def set_ACD(cli_runner, clean_vault): call(cli_runner, ["set", "C/D", "username=foo", "password=bar"]) -def test_boostrap_env(set_ACD): +def test_boostrap_env(set_ACD, cli_runner): env = subprocess.check_output( - "vault-cli env -p A -p C -p C/D:password=PASS -- env".split() + "python -m vault_cli env -p A -p C -p C/D:password=PASS -- env".split() ) assert b"A_VALUE=B\n" in env @@ -194,7 +194,7 @@ def test_ssh(clean_vault, cli_runner): ["set", "ssh_key", f"private={ssh_private}", f"passphrase={ssh_passphrase}"], ) identities = subprocess.run( - "vault-cli ssh --key ssh_key:private --passphrase ssh_key:passphrase " + "python -m vault_cli ssh --key ssh_key:private --passphrase ssh_key:passphrase " "-- ssh-add -L".split(), check=True, stdout=subprocess.PIPE, @@ -223,5 +223,5 @@ def umask(): def test_umask(set_ACD, umask, tmp_path, flag, expected): path = tmp_path / "test_boostrap_env" # umask = 0o000 => permissions = 0o666 - 0o000 = 0o666 - subprocess.check_output(f"vault-cli {flag}get A -o {path}".split()) + subprocess.check_output(f"python -m vault_cli {flag}get A -o {path}".split()) assert oct(path.stat().st_mode & 0o777) == expected diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b5f5df3..ba003a4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -22,6 +22,8 @@ def test_options(cli_runner, mocker): [ "--base-path", "bla", + "--namespace", + "foobar", "--ca-bundle", "yay", "--login-cert", @@ -51,6 +53,7 @@ def test_options(cli_runner, mocker): "password", "safe_write", "token", + "namespace", "url", "username", "verify", @@ -64,6 +67,7 @@ def test_options(cli_runner, mocker): assert kwargs["url"] == "https://foo" assert kwargs["username"] == "user" assert kwargs["verify"] is True + assert kwargs["namespace"] is "foobar" @pytest.fixture diff --git a/tests/unit/test_client_base.py b/tests/unit/test_client_base.py index 3e272da..d8231e9 100644 --- a/tests/unit/test_client_base.py +++ b/tests/unit/test_client_base.py @@ -36,6 +36,7 @@ def _authenticate_certificate(self, *args, **kwargs): "url": "yay", "login_cert": "a", "login_cert_key": "b", + "namespace": None } diff --git a/tests/unit/test_client_hvac.py b/tests/unit/test_client_hvac.py deleted file mode 100644 index da232c4..0000000 --- a/tests/unit/test_client_hvac.py +++ /dev/null @@ -1,192 +0,0 @@ -import json - -import hvac -import pytest -import requests - -from vault_cli import client, exceptions - -""" -In this module, we only check that we call hvac the way we meant to. -Testing that we work correctly with hvac as a whole is done in the integration -test. -""" - - -def get_client(**additional_kwargs): - kwargs = { - "url": "http://vault:8000", - "verify": True, - "base_path": "bla", - "login_cert": None, - "login_cert_key": None, - "token": "tok", - "username": None, - "password": None, - "ca_bundle": None, - } - kwargs.update(additional_kwargs) - return client.get_client(**kwargs) - - -@pytest.fixture -def mock_hvac_class(mocker): - yield mocker.patch("hvac.Client") - - -@pytest.fixture -def mock_hvac(mock_hvac_class): - yield mock_hvac_class.return_value - - -def test_token(mock_hvac): - get_client() - - assert mock_hvac.token == "tok" - - -def test_userpass(mock_hvac): - - get_client(token=None, username="myuser", password="pass") - - mock_hvac.auth_userpass.assert_called_with("myuser", "pass") - - -def test_certificate(mock_hvac_class, mock_hvac): - - get_client(token=None, login_cert="a", login_cert_key="b") - - assert mock_hvac_class.call_args[1]["cert"] == ("a", "b") - mock_hvac.auth_tls.assert_called_with() - - -def test_get_secret(mock_hvac): - - mock_hvac.read.return_value = {"data": {"value": "b"}} - - assert get_client()._get_secret("bla/a") == {"value": "b"} - - mock_hvac.read.assert_called_with("bla/a") - - -def test_get_secret_not_found(mock_hvac): - - mock_hvac.read.return_value = None - - with pytest.raises(exceptions.VaultAPIException): - assert get_client()._get_secret("bla/a") - - mock_hvac.read.assert_called_with("bla/a") - - -def test_get_secret_no_verify(): - client_obj = get_client(verify=False) - - assert client_obj.session.verify is False - - -def test_list_secrets(mock_hvac): - mock_hvac.list.return_value = {"data": {"keys": ["b"]}} - - assert get_client()._list_secrets("bla/a") == ["b"] - - mock_hvac.list.assert_called_with("bla/a") - - -def test_list_secrets_sorted(mock_hvac): - mock_hvac.list.return_value = {"data": {"keys": ["b", "A", "c"]}} - - assert get_client()._list_secrets("bla/a") == ["A", "b", "c"] - - -def test_list_secrets_empty(mock_hvac): - mock_hvac.list.return_value = None - - assert get_client()._list_secrets("bla/a") == [] - - mock_hvac.list.assert_called_with("bla/a") - - -def test_delete_secret(mock_hvac): - - get_client().delete_secret("a") - - mock_hvac.delete.assert_called_with("/bla/a") - - -def test_delete_secret_one_key(mock_hvac): - mock_hvac.read.return_value = {"data": {"value": "b"}} - - get_client().delete_secret("a", "value") - - mock_hvac.delete.assert_called_with("/bla/a") - - -def test_delete_secret_many_keys(mock_hvac): - mock_hvac.read.return_value = {"data": {"a": "A", "b": "B"}} - - get_client().delete_secret("a", "b") - - mock_hvac.delete.assert_not_called() - mock_hvac.write.assert_called_with("/bla/a", a="A") - - -@pytest.mark.parametrize("existing_mapping", [None, {"data": {"a": "A", "b": "B"}}]) -def test_delete_secret_missing_key_or_mapping(mock_hvac, existing_mapping): - mock_hvac.read.return_value = existing_mapping - - get_client().delete_secret("a", "c") - - mock_hvac.delete.assert_not_called() - mock_hvac.write.assert_not_called() - - -def test_set_secret(mock_hvac): - - get_client()._set_secret("bla/a", {"value": "b"}) - - mock_hvac.write.assert_called_with("bla/a", value="b") - - -def test_lookup_token(mock_hvac): - - get_client()._lookup_token() - - mock_hvac.lookup_token.assert_called_with() - - -def test_set_context_manager(mocker): - client_obj = get_client() - - session_exit = mocker.patch.object(client_obj.session, "__exit__") - - assert not session_exit.called - - with client_obj as c: - assert client_obj is c - - assert session_exit.called - - -@pytest.mark.parametrize( - "hvac_exc, vault_cli_exc", - [ - (hvac.exceptions.Forbidden, exceptions.VaultForbidden), - (hvac.exceptions.InvalidRequest, exceptions.VaultInvalidRequest), - (hvac.exceptions.Unauthorized, exceptions.VaultUnauthorized), - (hvac.exceptions.InternalServerError, exceptions.VaultInternalServerError), - (hvac.exceptions.VaultDown, exceptions.VaultSealed), - (hvac.exceptions.UnexpectedError, exceptions.VaultAPIException), - (requests.exceptions.ConnectionError, exceptions.VaultConnectionError), - ], -) -def test_handle_errors(hvac_exc, vault_cli_exc): - with pytest.raises(vault_cli_exc): - with client.handle_errors(): - raise hvac_exc - - -def test_handle_error_json(): - with pytest.raises(exceptions.VaultNonJsonResponse): - with client.handle_errors(): - json.loads("{") diff --git a/tests/unit/test_client_hvac_v1.py b/tests/unit/test_client_hvac_v1.py new file mode 100644 index 0000000..5723fd8 --- /dev/null +++ b/tests/unit/test_client_hvac_v1.py @@ -0,0 +1,199 @@ +import json + +import hvac +import pytest +import requests + +from vault_cli import client, exceptions + +""" +In this module, we only check that we call hvac the way we meant to. +Testing that we work correctly with hvac as a whole is done in the integration +test. +""" + + +def get_client(**additional_kwargs): + kwargs = { + "url": "http://vault:8000", + "verify": True, + "base_path": "bla", + "login_cert": None, + "login_cert_key": None, + "token": "tok", + "username": None, + "password": None, + "ca_bundle": None, + } + kwargs.update(additional_kwargs) + return client.get_client(**kwargs) + + +@pytest.fixture +def mock_hvac_v1_class(mocker): + client = mocker.patch("hvac.Client") + subclient=client.return_value + mocker.patch.object(subclient, "read", return_value={"data":{"options": {"version":"1"}}}) + mocker.patch.object(subclient.secrets.kv.v1, "create_or_update_secret", return_value=None) + mocker.patch.object(subclient.secrets.kv.v1, "read_secret", return_value=None) + mocker.patch.object(subclient.secrets.kv.v1, "delete_secret", return_value=None) + mocker.patch.object(subclient.secrets.kv.v1, "list_secrets", return_value=None) + yield client + #yield mocker.patch("hvac.Client", lambda *_, **__:client) + +@pytest.fixture +def mock_hvac_v1(mock_hvac_v1_class): + #yield mock_hvac_v1_class + yield mock_hvac_v1_class.return_value + + +def test_token(mock_hvac_v1): + get_client() + assert mock_hvac_v1.token == "tok" + + +def test_userpass(mock_hvac_v1): + + get_client(token=None, username="myuser", password="pass") + + mock_hvac_v1.auth_userpass.assert_called_with("myuser", "pass") + + +def test_certificate(mock_hvac_v1_class, mock_hvac_v1): + + get_client(token=None, login_cert="a", login_cert_key="b") + + assert mock_hvac_v1_class.call_args[1]["cert"] == ("a", "b") + mock_hvac_v1.auth_tls.assert_called_with() + + +def test_get_secret(mock_hvac_v1): + + mock_hvac_v1.secrets.kv.v1.read_secret.return_value = {"data": {"value": "b"}} + + assert get_client()._get_secret("bla/a") == {"value": "b"} + + mock_hvac_v1.secrets.kv.v1.read_secret.assert_called_with("bla/a", mount_point="bla") + + +def test_get_secret_not_found(mock_hvac_v1): + + mock_hvac_v1.secrets.kv.v1.read_secret.return_value = None + + with pytest.raises(exceptions.VaultAPIException): + assert get_client()._get_secret("bla/a") + + mock_hvac_v1.secrets.kv.v1.read_secret.assert_called_with("bla/a", mount_point="bla") + + +def test_get_secret_no_verify(): + client_obj = get_client(verify=False) + + assert client_obj.session.verify is False + + +def test_list_secrets(mock_hvac_v1): + mock_hvac_v1.secrets.kv.v1.list_secrets.return_value = {"data": {"keys": ["b"]}} + + assert get_client()._list_secrets("bla/a") == ["b"] + + mock_hvac_v1.secrets.kv.v1.list_secrets.assert_called_with("bla/a", mount_point="bla") + + +def test_list_secrets_sorted(mock_hvac_v1): + mock_hvac_v1.secrets.kv.v1.list_secrets.return_value = {"data": {"keys": ["b", "A", "c"]}} + + assert get_client()._list_secrets("bla/a") == ["A", "b", "c"] + + +def test_list_secrets_empty(mock_hvac_v1): + mock_hvac_v1.secrets.kv.v1.list_secrets.return_value = None + + assert get_client()._list_secrets("bla/a") == [] + + mock_hvac_v1.secrets.kv.v1.list_secrets.assert_called_with("bla/a", mount_point="bla") + + +def test_delete_secret(mock_hvac_v1): + + get_client().delete_secret("a") + + mock_hvac_v1.secrets.kv.v1.delete_secret.assert_called_with("a", mount_point="bla") + + +def test_delete_secret_one_key(mock_hvac_v1): + mock_hvac_v1.secrets.kv.v1.read_secret.return_value = {"data": {"value": "b"}} + + get_client().delete_secret("a", "value") + + mock_hvac_v1.secrets.kv.v1.delete_secret.assert_called_with("a", mount_point="bla") + + +def test_delete_secret_many_keys(mock_hvac_v1): + mock_hvac_v1.secrets.kv.v1.read_secret.return_value = {"data": {"a": "A", "b": "B"}} + + get_client().delete_secret("a", "b") + + mock_hvac_v1.secrets.kv.v1.delete_secret.assert_not_called() + mock_hvac_v1.secrets.kv.v1.create_or_update_secret.assert_called_with("a", secret={"a":"A"}, mount_point="bla") + + +@pytest.mark.parametrize("existing_mapping", [None, {"data": {"a": "A", "b": "B"}}]) +def test_delete_secret_missing_key_or_mapping(mock_hvac_v1, existing_mapping): + mock_hvac_v1.secrets.kv.v1.read_secret.return_value = existing_mapping + + get_client().delete_secret("a", "c") + + mock_hvac_v1.secrets.kv.v1.delete_secret.assert_not_called() + mock_hvac_v1.secrets.kv.v1.create_or_update_secret.assert_not_called() + + +def test_set_secret(mock_hvac_v1): + + get_client()._set_secret("bla/a", {"value": "b"}) + + mock_hvac_v1.secrets.kv.v1.create_or_update_secret.assert_called_with("bla/a", secret={"value": "b"}, mount_point="bla") + + +def test_lookup_token(mock_hvac_v1): + + get_client()._lookup_token() + + mock_hvac_v1.lookup_token.assert_called_with() + + +def test_set_context_manager(mocker): + client_obj = get_client() + + session_exit = mocker.patch.object(client_obj.session, "__exit__") + + assert not session_exit.called + + with client_obj as c: + assert client_obj is c + + assert session_exit.called + + +@pytest.mark.parametrize( + "hvac_exc, vault_cli_exc", + [ + (hvac.exceptions.Forbidden, exceptions.VaultForbidden), + (hvac.exceptions.InvalidRequest, exceptions.VaultInvalidRequest), + (hvac.exceptions.Unauthorized, exceptions.VaultUnauthorized), + (hvac.exceptions.InternalServerError, exceptions.VaultInternalServerError), + (hvac.exceptions.VaultDown, exceptions.VaultSealed), + (hvac.exceptions.UnexpectedError, exceptions.VaultAPIException), + (requests.exceptions.ConnectionError, exceptions.VaultConnectionError), + ], +) +def test_handle_errors(hvac_exc, vault_cli_exc): + with pytest.raises(vault_cli_exc): + with client.handle_errors(): + raise hvac_exc + + +def test_handle_error_json(): + with pytest.raises(exceptions.VaultNonJsonResponse): + with client.handle_errors(): + json.loads("{") diff --git a/tests/unit/test_client_hvac_v2.py b/tests/unit/test_client_hvac_v2.py new file mode 100644 index 0000000..d330c59 --- /dev/null +++ b/tests/unit/test_client_hvac_v2.py @@ -0,0 +1,199 @@ +import json + +import hvac +import pytest +import requests + +from vault_cli import client, exceptions + +""" +In this module, we only check that we call hvac the way we meant to. +Testing that we work correctly with hvac as a whole is done in the integration +test. +""" + + +def get_client(**additional_kwargs): + kwargs = { + "url": "http://vault:8000", + "verify": True, + "base_path": "bla", + "login_cert": None, + "login_cert_key": None, + "token": "tok", + "username": None, + "password": None, + "ca_bundle": None, + } + kwargs.update(additional_kwargs) + return client.get_client(**kwargs) + + +@pytest.fixture +def mock_hvac_v2_class(mocker): + client = mocker.patch("hvac.Client") + subclient=client.return_value + mocker.patch.object(subclient, "read", return_value={"data":{"options": {"version":"2"}}}) + mocker.patch.object(subclient.secrets.kv.v2, "create_or_update_secret", return_value=None) + mocker.patch.object(subclient.secrets.kv.v2, "read_secret", return_value=None) + mocker.patch.object(subclient.secrets.kv.v2, "delete_metadata_and_all_versions", return_value=None) + mocker.patch.object(subclient.secrets.kv.v2, "list_secrets", return_value=None) + yield client + #yield mocker.patch("hvac.Client", lambda *_, **__:client) + +@pytest.fixture +def mock_hvac_v2(mock_hvac_v2_class): + #yield mock_hvac_v2_class + yield mock_hvac_v2_class.return_value + + +def test_token(mock_hvac_v2): + get_client() + assert mock_hvac_v2.token == "tok" + + +def test_userpass(mock_hvac_v2): + + get_client(token=None, username="myuser", password="pass") + + mock_hvac_v2.auth_userpass.assert_called_with("myuser", "pass") + + +def test_certificate(mock_hvac_v2_class, mock_hvac_v2): + + get_client(token=None, login_cert="a", login_cert_key="b") + + assert mock_hvac_v2_class.call_args[1]["cert"] == ("a", "b") + mock_hvac_v2.auth_tls.assert_called_with() + + +def test_get_secret(mock_hvac_v2): + + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"value": "b"}} + + assert get_client()._get_secret("bla/a") == {"value": "b"} + + mock_hvac_v2.secrets.kv.v2.read_secret.assert_called_with("bla/a", mount_point="bla") + + +def test_get_secret_not_found(mock_hvac_v2): + + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = None + + with pytest.raises(exceptions.VaultAPIException): + assert get_client()._get_secret("bla/a") + + mock_hvac_v2.secrets.kv.v2.read_secret.assert_called_with("bla/a", mount_point="bla") + + +def test_get_secret_no_verify(): + client_obj = get_client(verify=False) + + assert client_obj.session.verify is False + + +def test_list_secrets(mock_hvac_v2): + mock_hvac_v2.secrets.kv.v2.list_secrets.return_value = {"data": {"keys": ["b"]}} + + assert get_client()._list_secrets("bla/a") == ["b"] + + mock_hvac_v2.secrets.kv.v2.list_secrets.assert_called_with("bla/a", mount_point="bla") + + +def test_list_secrets_sorted(mock_hvac_v2): + mock_hvac_v2.secrets.kv.v2.list_secrets.return_value = {"data": {"keys": ["b", "A", "c"]}} + + assert get_client()._list_secrets("bla/a") == ["A", "b", "c"] + + +def test_list_secrets_empty(mock_hvac_v2): + mock_hvac_v2.secrets.kv.v2.list_secrets.return_value = None + + assert get_client()._list_secrets("bla/a") == [] + + mock_hvac_v2.secrets.kv.v2.list_secrets.assert_called_with("bla/a", mount_point="bla") + + +def test_delete_secret(mock_hvac_v2): + + get_client().delete_secret("a") + + mock_hvac_v2.secrets.kv.v2.delete_metadata_and_all_versions.assert_called_with("a", mount_point="bla") + + +def test_delete_secret_one_key(mock_hvac_v2): + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"value": "b"}} + + get_client().delete_secret("a", "value") + + mock_hvac_v2.secrets.kv.v2.delete_metadata_and_all_versions.assert_called_with("a", mount_point="bla") + + +def test_delete_secret_many_keys(mock_hvac_v2): + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"a": "A", "b": "B"}} + + get_client().delete_secret("a", "b") + + mock_hvac_v2.secrets.kv.v2.delete_metadata_and_all_versions.assert_not_called() + mock_hvac_v2.secrets.kv.v2.create_or_update_secret.assert_called_with("a", secret={"a":"A"}, mount_point="bla") + + +@pytest.mark.parametrize("existing_mapping", [None, {"data": {"a": "A", "b": "B"}}]) +def test_delete_secret_missing_key_or_mapping(mock_hvac_v2, existing_mapping): + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = existing_mapping + + get_client().delete_secret("a", "c") + + mock_hvac_v2.secrets.kv.v2.delete_metadata_and_all_versions.assert_not_called() + mock_hvac_v2.secrets.kv.v2.create_or_update_secret.assert_not_called() + + +def test_set_secret(mock_hvac_v2): + + get_client()._set_secret("bla/a", {"value": "b"}) + + mock_hvac_v2.secrets.kv.v2.create_or_update_secret.assert_called_with("bla/a", secret={"value": "b"}, mount_point="bla") + + +def test_lookup_token(mock_hvac_v2): + + get_client()._lookup_token() + + mock_hvac_v2.lookup_token.assert_called_with() + + +def test_set_context_manager(mocker): + client_obj = get_client() + + session_exit = mocker.patch.object(client_obj.session, "__exit__") + + assert not session_exit.called + + with client_obj as c: + assert client_obj is c + + assert session_exit.called + + +@pytest.mark.parametrize( + "hvac_exc, vault_cli_exc", + [ + (hvac.exceptions.Forbidden, exceptions.VaultForbidden), + (hvac.exceptions.InvalidRequest, exceptions.VaultInvalidRequest), + (hvac.exceptions.Unauthorized, exceptions.VaultUnauthorized), + (hvac.exceptions.InternalServerError, exceptions.VaultInternalServerError), + (hvac.exceptions.VaultDown, exceptions.VaultSealed), + (hvac.exceptions.UnexpectedError, exceptions.VaultAPIException), + (requests.exceptions.ConnectionError, exceptions.VaultConnectionError), + ], +) +def test_handle_errors(hvac_exc, vault_cli_exc): + with pytest.raises(vault_cli_exc): + with client.handle_errors(): + raise hvac_exc + + +def test_handle_error_json(): + with pytest.raises(exceptions.VaultNonJsonResponse): + with client.handle_errors(): + json.loads("{") diff --git a/vault_cli/__init__.py b/vault_cli/__init__.py index f8fcf5d..b19d004 100644 --- a/vault_cli/__init__.py +++ b/vault_cli/__init__.py @@ -29,9 +29,9 @@ "VaultSealed", ] -_metadata = metadata.extract_metadata() -__author__ = _metadata["author"] -__author_email__ = _metadata["email"] -__license__ = _metadata["license"] -__url__ = _metadata["url"] -__version__ = _metadata["version"] +#_metadata = metadata.extract_metadata() +# __author__ = _metadata["author"] +# __author_email__ = _metadata["email"] +# __license__ = _metadata["license"] +# __url__ = _metadata["url"] +# __version__ = _metadata["version"] diff --git a/vault_cli/client.py b/vault_cli/client.py index af35f89..1092c8f 100644 --- a/vault_cli/client.py +++ b/vault_cli/client.py @@ -5,6 +5,7 @@ from typing import Dict, Iterable, List, Optional, Tuple, Type, cast import hvac # type: ignore +import hvac.exceptions # type: ignore import jinja2 import jinja2.sandbox import requests.packages.urllib3 # type: ignore @@ -61,6 +62,27 @@ def get_client(**kwargs) -> "VaultClientBase": def get_client_class() -> Type["VaultClientBase"]: return VaultClient +@contextlib.contextmanager +def handle_errors(): + try: + yield + except json.decoder.JSONDecodeError as exc: + raise exceptions.VaultNonJsonResponse(errors=[str(exc)]) + except hvac.exceptions.InvalidRequest as exc: + raise exceptions.VaultInvalidRequest(errors=exc.errors) from exc + except hvac.exceptions.Unauthorized as exc: + raise exceptions.VaultUnauthorized(errors=exc.errors) from exc + except hvac.exceptions.Forbidden as exc: + raise exceptions.VaultForbidden(errors=exc.errors) from exc + except hvac.exceptions.InternalServerError as exc: + raise exceptions.VaultInternalServerError(errors=exc.errors) from exc + except hvac.exceptions.VaultDown as exc: + raise exceptions.VaultSealed(errors=exc.errors) from exc + except hvac.exceptions.UnexpectedError as exc: + raise exceptions.VaultAPIException(errors=exc.errors) from exc + except requests.exceptions.ConnectionError as exc: + raise exceptions.VaultConnectionError() from exc + class VaultClientBase: @@ -93,6 +115,7 @@ def __init__( self.namespace = namespace self.cache: Dict[str, types.JSONDict] = {} self.errors: List[str] = [] + self.kv_engines: Dict[str, types.HVACMethods] = {} @property @@ -155,13 +178,43 @@ def __exit__(self, exc_type, exc_value, traceback): self.cache = {} self.errors = [] + def _setup_kv_v2_methods(self, mount_point): + self.kv_engines[mount_point] = { + "read": self.client.secrets.kv.v2.read_secret, + "write": self.client.secrets.kv.v2.create_or_update_secret, + "delete": self.client.secrets.kv.v2.delete_metadata_and_all_versions, + "list": self.client.secrets.kv.v2.list_secrets, + } + + def _setup_kv_v1_methods(self, mount_point): + self.kv_engines[mount_point] = { + "read": self.client.secrets.kv.v1.read_secret, + "write": self.client.secrets.kv.v1.create_or_update_secret, + "delete": self.client.secrets.kv.v1.delete_secret, + "list": self.client.secrets.kv.v1.list_secrets, + } + + @handle_errors() + def _setup_kv_engine(self, path): + mountpoint = utils.extract_mountpoint(path) + if not self.kv_engines.get(mountpoint): + mount_config = self.client.read(f"/sys/internal/ui/mounts/{mountpoint}").get("data") + kv_version = mount_config.get("options",{}).get("version", "1") + if kv_version == "1": + self._setup_kv_v1_methods(mountpoint) + elif kv_version == "2": + self._setup_kv_v2_methods(mountpoint) + self.vault_methods = self.kv_engines[mountpoint] + def _build_full_path(self, path: str) -> str: - if path.startswith("/"): - # absolute path - return path - else: - # path relative to base_path - return self.base_path + path + full_path = path if path.startswith("/") else self.base_path + path + self._setup_kv_engine(full_path) + return full_path + + def _extract_mount_secret_path(self, path) -> Tuple[str, str]: + fullpath = self._build_full_path(path) + parts = list(filter(None,fullpath.split("/"))) + return parts[0], "/".join(parts[1:]) or "/" def _browse_recursive_secrets(self, path: str) -> Iterable[str]: """ @@ -690,29 +743,6 @@ def lookup_token(self) -> types.JSONDict: def _lookup_token(self) -> types.JSONDict: raise NotImplementedError - -@contextlib.contextmanager -def handle_errors(): - try: - yield - except json.decoder.JSONDecodeError as exc: - raise exceptions.VaultNonJsonResponse(errors=[str(exc)]) - except hvac.exceptions.InvalidRequest as exc: - raise exceptions.VaultInvalidRequest(errors=exc.errors) from exc - except hvac.exceptions.Unauthorized as exc: - raise exceptions.VaultUnauthorized(errors=exc.errors) from exc - except hvac.exceptions.Forbidden as exc: - raise exceptions.VaultForbidden(errors=exc.errors) from exc - except hvac.exceptions.InternalServerError as exc: - raise exceptions.VaultInternalServerError(errors=exc.errors) from exc - except hvac.exceptions.VaultDown as exc: - raise exceptions.VaultSealed(errors=exc.errors) from exc - except hvac.exceptions.UnexpectedError as exc: - raise exceptions.VaultAPIException(errors=exc.errors) from exc - except requests.exceptions.ConnectionError as exc: - raise exceptions.VaultConnectionError() from exc - - class VaultClient(VaultClientBase): @handle_errors() def _init_client( @@ -747,27 +777,38 @@ def _authenticate_certificate(self) -> None: @handle_errors() def _list_secrets(self, path: str) -> Iterable[str]: - secrets = self.client.list(path) + mount_point, path = self._extract_mount_secret_path(path) + try: + secrets = self.vault_methods["list"](path, mount_point=mount_point) + except hvac.exceptions.InvalidPath: # 404 No Secret found + return [] if not secrets: return [] return sorted(secrets["data"]["keys"]) @handle_errors() def _get_secret(self, path: str) -> Dict[str, types.JSONValue]: - secret = self.client.read(path) - if not secret: + mount_point, path = self._extract_mount_secret_path(path) + try: + secret = self.vault_methods["read"](path, mount_point=mount_point) + if not secret: + raise hvac.exceptions.InvalidPath() + except hvac.exceptions.InvalidPath: # 404 No Secret found raise exceptions.VaultSecretNotFound( errors=[f"Secret not found at path '{path}'"] - ) + ) return secret["data"] @handle_errors() def _delete_secret(self, path: str) -> None: - self.client.delete(path) + mount_point, path = self._extract_mount_secret_path(path) + self.vault_methods["delete"](path, mount_point=mount_point) + #self.client.delete(path) @handle_errors() def _set_secret(self, path: str, secret: Dict[str, types.JSONValue]) -> None: - self.client.write(path, **secret) + mount_point, path = self._extract_mount_secret_path(path) + self.vault_methods["write"](path, secret=secret, mount_point=mount_point) @handle_errors() def _lookup_token(self) -> types.JSONDict: @@ -775,4 +816,4 @@ def _lookup_token(self) -> types.JSONDict: def __exit__(self, exc_type, exc_value, traceback): super().__exit__(exc_type, exc_value, traceback) - self.session.__exit__(exc_type, exc_value, traceback) + self.session.__exit__(exc_type, exc_value, traceback) \ No newline at end of file diff --git a/vault_cli/test.py b/vault_cli/test.py new file mode 100644 index 0000000..d9f18a7 --- /dev/null +++ b/vault_cli/test.py @@ -0,0 +1,30 @@ +import typing as t +from requests import Response as HTTPResponse + +JSONValue = t.Union[str, int, float, bool, None, t.Dict[str, t.Any], t.List[t.Any]] +JSONDict = t.Dict[str, JSONValue] + +class V1: + def list(self, path:str, mount:t.Optional[str]="default") -> JSONDict: + return {"r":"toutou"} + def read(self, path:str, mount:t.Optional[str]="default") -> JSONDict: + return {"r":"toutou"} + def delete(self, path:str, mount:t.Optional[str]="default") -> HTTPResponse: + return HTTPResponse() + def create(self, path:str, secret:JSONDict, method:t.Optional[str]=None, mount:t.Optional[str]="default") -> HTTPResponse: + return HTTPResponse() + +class V2: + def list(self, path:str, mount:str="default") -> JSONDict: + return {"r":"toutou"} + def read(self, path:str, version:t.Optional[int]=None ,mount:t.Optional[str]="default") -> JSONDict: + return {"r":"toutou"} + def delete(self, path:str, mount:str="default") -> HTTPResponse: + return HTTPResponse() + def create(self, path:str, secret:JSONDict, method:t.Optional[str]=None, mount:t.Optional[str]="default") -> JSONDict: + return {"r":"toutou"} + +def f(yolo: t.Callable[[str, t.Optional[str]], JSONDict]): + pass +v1 = V1() +f(v1.create) \ No newline at end of file diff --git a/vault_cli/testing.py b/vault_cli/testing.py index 2d6c758..84323c1 100644 --- a/vault_cli/testing.py +++ b/vault_cli/testing.py @@ -16,6 +16,11 @@ def __init__(self, **kwargs): def _init_client(self, *args, **kwargs): pass + def _build_full_path(self, path: str) -> str: + full_path = path if path.startswith("/") else self.base_path + path + return full_path + + def _authenticate_token(self, *args, **kwargs): pass diff --git a/vault_cli/types.py b/vault_cli/types.py index ad7c4d1..734c51d 100644 --- a/vault_cli/types.py +++ b/vault_cli/types.py @@ -1,4 +1,6 @@ import typing as t +import typing_extensions as te +import requests as r JSONValue = t.Union[str, int, float, bool, None, t.Dict[str, t.Any], t.List[t.Any]] JSONDict = t.Dict[str, JSONValue] @@ -7,3 +9,12 @@ SettingsDict = t.Dict[str, Settings] VerifyOrCABundle = t.Union[bool, str] + +VaultMethod = t.Literal["write","read","list","delete"] +HVACMethods = te.TypedDict("HVACMethods", { + "list": t.Any, + "delete": t.Any, + "read": t.Any, + "write": t.Any, +}) + diff --git a/vault_cli/utils.py b/vault_cli/utils.py index e0109df..8f2bfc0 100644 --- a/vault_cli/utils.py +++ b/vault_cli/utils.py @@ -40,3 +40,7 @@ def extract_error_messages(exc: BaseException) -> Iterable[str]: if not opt_exc: break exc = opt_exc + +def extract_mountpoint(path: str) -> str: + return next(filter(None,path.split("/"))) + From 66a21abdd678687ee8cf0623ea497f3453734f98 Mon Sep 17 00:00:00 2001 From: Jean-Yves NOLEN Date: Mon, 23 Jan 2023 10:46:14 +0100 Subject: [PATCH 4/4] Add KvV2 support --- tests/conftest.py | 1 - tests/integration/test_integration.py | 185 +++++++++++++++++++++++--- tests/unit/test_cli.py | 10 +- tests/unit/test_client_hvac_v2.py | 8 +- vault.kv1.yml | 1 + vault.kv2.yml | 1 + vault.token.yml | 2 +- vault_cli/__init__.py | 12 +- vault_cli/__main__.py | 0 vault_cli/client.py | 11 +- 10 files changed, 199 insertions(+), 32 deletions(-) create mode 120000 vault.kv1.yml create mode 120000 vault.kv2.yml mode change 100644 => 100755 vault_cli/__main__.py diff --git a/tests/conftest.py b/tests/conftest.py index 879912e..fccd1b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,6 @@ @pytest.fixture(autouse=True) def isolate_tests(): - for key in os.environ: if key.startswith(settings.ENV_PREFIX): os.environ.pop(key) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index c36bdd0..1bb7ab8 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -3,12 +3,28 @@ import pytest +import yaml import vault_cli from vault_cli import cli +@pytest.fixture() +def kvv2_config(tmp_path): + with tmp_path.joinpath("vault.kv2.yml").open("w") as fw: + yaml.dump({"url": "https://localhost:8443", "base_path": "secret/", "ca_bundle": "server-chain.crt", "token": "some-token"}, fw) + os.environ["TEST_INTEGRATION_CONFIG_FILE"] = str(tmp_path.joinpath("vault.kv2.yml")) + yield str(tmp_path.joinpath("vault.kv2.yml")) + +@pytest.fixture() +def kvv1_config(tmp_path): + with tmp_path.joinpath("vault.kv1.yml").open("w") as fw: + yaml.dump({"url": "https://localhost:8443", "base_path": "secretkvv1/", "ca_bundle": "server-chain.crt", "token": "some-token"}, fw) + os.environ["TEST_INTEGRATION_CONFIG_FILE"] = str(tmp_path.joinpath("vault.kv1.yml")) + yield str(tmp_path.joinpath("vault.kv1.yml")) def call(cli_runner, *args, **kwargs): - call = cli_runner.invoke(cli.cli, *args, **kwargs) + _args = list(args) + _args[0] = ["--config-file", os.environ.get("TEST_INTEGRATION_CONFIG_FILE")] + _args[0] + call = cli_runner.invoke(cli.cli, *tuple(_args), **kwargs) assert call.exit_code == 0, call.output return call @@ -20,7 +36,7 @@ def clean_vault(cli_runner): call(cli_runner, ["delete-all", "-f"]) -def test_integration_cli(cli_runner, clean_vault): +def test_integration_cli_kvv1(kvv1_config, cli_runner, clean_vault): call(cli_runner, ["set", "a", "value=b"]) @@ -80,7 +96,7 @@ def test_integration_cli(cli_runner, clean_vault): call(cli_runner, ["delete", "c/d", "foo"]) - result = cli_runner.invoke(cli.cli, ["get", "c/d", "foo"]) + result = cli_runner.invoke(cli.cli, ["--config-file", kvv1_config ,"get", "c/d", "foo"]) assert result.exit_code == 1 assert ( result.output @@ -99,9 +115,9 @@ def test_integration_cli(cli_runner, clean_vault): assert call(cli_runner, ["lookup-token"]).output.startswith("---\nauth:") -def test_integration_lib(clean_vault): +def test_integration_lib_kvv1(kvv1_config, clean_vault): - client = vault_cli.get_client() + client = vault_cli.get_client(config_file=kvv1_config) client.set_secret("a", {"value": "b"}) @@ -143,11 +159,128 @@ def test_integration_lib(clean_vault): assert client.get_secret("novalue") == {"password": "pass", "username": "name"} -def test_env_var_config(): +def test_integration_cli_kvv2(kvv2_config, cli_runner, clean_vault): + + call(cli_runner, ["set", "a", "value=b"]) + + assert call(cli_runner, ["get", "a"]).output == "---\nvalue: b\n" + + assert call(cli_runner, ["get", "a", "--yaml"]).output == "---\nvalue: b\n" + + assert call(cli_runner, ["get", "a", "value"]).output == "b\n" + + assert call(cli_runner, ["get", "a", "value", "--yaml"]).output == "--- b\n...\n" + + call(cli_runner, ["set", "c", "--file=-"], input="{'key1':'val1', 'key2':'val2'}") + + assert call(cli_runner, ["get", "c"]).output == "---\nkey1: val1\nkey2: val2\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", "value=b"]) + + assert call(cli_runner, ["list"]).output == "a\n" + + call(cli_runner, ["set", "c/d", "foo=e", "bar=f"]) + + assert call(cli_runner, ["get", "c/d", "foo"]).output == "e\n" + + assert call(cli_runner, ["list"]).output == "a\nc/\n" + + assert call(cli_runner, ["list", "c"]).output == "d\n" + + assert call(cli_runner, ["get-all", ""]).output == ( + """--- +a: + value: b +c/d: + bar: f + foo: e +""" + ) + + assert call(cli_runner, ["get-all", "--no-flat"]).output == ( + """--- +a: + value: b +c: + d: + bar: f + foo: e +""" + ) + + call(cli_runner, ["delete", "a"]) + + assert call(cli_runner, ["list"]).output == "c/\n" + + call(cli_runner, ["delete", "c/d", "foo"]) + + result = cli_runner.invoke(cli.cli, ["--config-file", kvv2_config ,"get", "c/d", "foo"]) + assert result.exit_code == 1 + assert ( + result.output + == """Error: VaultSecretNotFound: Secret not found +Key 'foo' not found in secret at path '/secret/c/d' +KeyError: 'foo' +""" + ) + + assert call(cli_runner, ["list"]).output == "c/\n" + + call(cli_runner, ["delete-all", "--force"]) + + assert call(cli_runner, ["list"]).output == "\n" + + assert call(cli_runner, ["lookup-token"]).output.startswith("---\nauth:") + + +def test_integration_lib_kvv2(kvv2_config, clean_vault): + + client = vault_cli.get_client(config_file=kvv2_config) + + client.set_secret("a", {"value": "b"}) + + assert client.get_secret("a") == {"value": "b"} + + assert "a" in list(client.delete_all_secrets("")) + + assert client.list_secrets("") == [] + + client.set_secret("a", {"value": "b"}) + + assert client.list_secrets("") == ["a"] + + client.set_secret("c/d", {"name": "e"}) + + assert client.get_secret("c/d") == {"name": "e"} + + assert client.list_secrets("") == ["a", "c/"] + + assert client.list_secrets("c") == ["d"] + + assert client.get_all_secrets("") == { + "a": {"value": "b"}, + "c": {"d": {"name": "e"}}, + } + + client.delete_secret("a") + + assert client.list_secrets("") == ["c/"] + + assert list(client.delete_all_secrets("")) == ["c/d"] + + assert client.lookup_token()["data"] + + +def test_env_var_config(kvv1_config): # Test env var config os.environ["VAULT_CLI_TOKEN"] = "some-other-token" with pytest.raises(vault_cli.VaultAPIException): - vault_cli.get_client().set_secret("a", {"name": "value"}) + vault_cli.get_client(config_file=kvv1_config).set_secret("a", {"name": "value"}) def check_call(command): @@ -162,9 +295,19 @@ def set_ACD(cli_runner, clean_vault): call(cli_runner, ["set", "C/D", "username=foo", "password=bar"]) -def test_boostrap_env(set_ACD, cli_runner): +def test_boostrap_env_kvv1(kvv1_config, set_ACD, cli_runner): + env = subprocess.check_output( + f"python -m vault_cli --config-file {kvv1_config} env -p A -p C -p C/D:password=PASS -- env".split() + ) + + assert b"A_VALUE=B\n" in env + assert b"D_USERNAME=foo\n" in env + assert b"D_PASSWORD=bar\n" in env + assert b"PASS=bar\n" in env + +def test_boostrap_env_kvv2(kvv2_config, set_ACD, cli_runner): env = subprocess.check_output( - "python -m vault_cli env -p A -p C -p C/D:password=PASS -- env".split() + f"python -m vault_cli --config-file {kvv2_config} env -p A -p C -p C/D:password=PASS -- env".split() ) assert b"A_VALUE=B\n" in env @@ -173,7 +316,7 @@ def test_boostrap_env(set_ACD, cli_runner): assert b"PASS=bar\n" in env -def test_ssh(clean_vault, cli_runner): +def test_ssh(kvv1_config, clean_vault, cli_runner): # In case this is not sufficienlty explicit that this is a test key, then: # # THIS IS A TEST KEY, DO NOT USE IN THE REAL WORLD @@ -191,10 +334,10 @@ def test_ssh(clean_vault, cli_runner): ssh_passphrase = "foobar" call( cli_runner, - ["set", "ssh_key", f"private={ssh_private}", f"passphrase={ssh_passphrase}"], + ["--config-file", kvv1_config, "set", "ssh_key", f"private={ssh_private}", f"passphrase={ssh_passphrase}"], ) identities = subprocess.run( - "python -m vault_cli ssh --key ssh_key:private --passphrase ssh_key:passphrase " + f"python -m vault_cli --config-file {kvv1_config} ssh --key ssh_key:private --passphrase ssh_key:passphrase " "-- ssh-add -L".split(), check=True, stdout=subprocess.PIPE, @@ -202,7 +345,6 @@ def test_ssh(clean_vault, cli_runner): ) assert ssh_public in identities.stdout.decode("utf-8") assert "Identity added" not in identities.stdout.decode("utf-8") - assert identities.stderr.decode("utf-8") == "" @pytest.fixture @@ -220,8 +362,21 @@ def umask(): ("--umask=000 ", "0o666"), ], ) -def test_umask(set_ACD, umask, tmp_path, flag, expected): +def test_umask_kvv1(kvv1_config, set_ACD, umask, tmp_path, flag, expected): + path = tmp_path / "test_boostrap_env" + # umask = 0o000 => permissions = 0o666 - 0o000 = 0o666 + subprocess.check_output(f"python -m vault_cli --config-file {kvv1_config} {flag}get A -o {path}".split()) + assert oct(path.stat().st_mode & 0o777) == expected + +@pytest.mark.parametrize( + "flag, expected", + [ + ("", "0o600"), + ("--umask=000 ", "0o666"), + ], +) +def test_umask_kvv2(kvv2_config, set_ACD, umask, tmp_path, flag, expected): path = tmp_path / "test_boostrap_env" # umask = 0o000 => permissions = 0o666 - 0o000 = 0o666 - subprocess.check_output(f"python -m vault_cli {flag}get A -o {path}".split()) + subprocess.check_output(f"python -m vault_cli --config-file {kvv2_config} {flag}get A -o {path}".split()) assert oct(path.stat().st_mode & 0o777) == expected diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ba003a4..8c66b14 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -11,6 +11,12 @@ # To debug cli_runner.invoke, add the argument "catch_exceptions=False" +@pytest.fixture() +def kvv1_config(tmp_path): + with tmp_path.joinpath("vault.kv1.yml").open("w") as fw: + yaml.dump({"url": "https://localhost:8443", "base_path": "secretkvv1/", "ca_bundle": "server-chain.crt", "token": "some-token"}, fw) + os.environ["TEST_INTEGRATION_CONFIG_FILE"] = str(tmp_path.joinpath("vault.kv1.yml")) + yield str(tmp_path.joinpath("vault.kv1.yml")) def test_options(cli_runner, mocker): client = mocker.patch("vault_cli.client.get_client_class").return_value @@ -355,9 +361,9 @@ def test_env_error(cli_runner, vault_with_token, mocker): exec_command.assert_not_called() -def test_env_envvar_format_error(cli_runner): +def test_env_envvar_format_error(kvv1_config, cli_runner): result = cli_runner.invoke( - cli.cli, ["env", "--envvar", ":foo", "--", "echo", "yay"] + cli.cli, ["--config-file", kvv1_config, "env", "--envvar", ":foo", "--", "echo", "yay"] ) assert result.exit_code != 0 diff --git a/tests/unit/test_client_hvac_v2.py b/tests/unit/test_client_hvac_v2.py index d330c59..794050f 100644 --- a/tests/unit/test_client_hvac_v2.py +++ b/tests/unit/test_client_hvac_v2.py @@ -69,7 +69,7 @@ def test_certificate(mock_hvac_v2_class, mock_hvac_v2): def test_get_secret(mock_hvac_v2): - mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"value": "b"}} + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"data" : {"value": "b"}}} assert get_client()._get_secret("bla/a") == {"value": "b"} @@ -122,7 +122,7 @@ def test_delete_secret(mock_hvac_v2): def test_delete_secret_one_key(mock_hvac_v2): - mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"value": "b"}} + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": { "data" : {"value": "b"}}} get_client().delete_secret("a", "value") @@ -130,7 +130,7 @@ def test_delete_secret_one_key(mock_hvac_v2): def test_delete_secret_many_keys(mock_hvac_v2): - mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": {"a": "A", "b": "B"}} + mock_hvac_v2.secrets.kv.v2.read_secret.return_value = {"data": { "data" : {"a": "A", "b": "B"}}} get_client().delete_secret("a", "b") @@ -138,7 +138,7 @@ def test_delete_secret_many_keys(mock_hvac_v2): mock_hvac_v2.secrets.kv.v2.create_or_update_secret.assert_called_with("a", secret={"a":"A"}, mount_point="bla") -@pytest.mark.parametrize("existing_mapping", [None, {"data": {"a": "A", "b": "B"}}]) +@pytest.mark.parametrize("existing_mapping", [None, {"data": {"data" :{"a": "A", "b": "B"}}}]) def test_delete_secret_missing_key_or_mapping(mock_hvac_v2, existing_mapping): mock_hvac_v2.secrets.kv.v2.read_secret.return_value = existing_mapping diff --git a/vault.kv1.yml b/vault.kv1.yml new file mode 120000 index 0000000..51a63c2 --- /dev/null +++ b/vault.kv1.yml @@ -0,0 +1 @@ +vault.token.yml \ No newline at end of file diff --git a/vault.kv2.yml b/vault.kv2.yml new file mode 120000 index 0000000..51a63c2 --- /dev/null +++ b/vault.kv2.yml @@ -0,0 +1 @@ +vault.token.yml \ No newline at end of file diff --git a/vault.token.yml b/vault.token.yml index 37ec53b..0e57a45 100644 --- a/vault.token.yml +++ b/vault.token.yml @@ -1,5 +1,5 @@ --- url: https://localhost:8443 -base_path: secretkvv1/ +base_path: secret/ ca_bundle: server-chain.crt token: some-token diff --git a/vault_cli/__init__.py b/vault_cli/__init__.py index b19d004..f8fcf5d 100644 --- a/vault_cli/__init__.py +++ b/vault_cli/__init__.py @@ -29,9 +29,9 @@ "VaultSealed", ] -#_metadata = metadata.extract_metadata() -# __author__ = _metadata["author"] -# __author_email__ = _metadata["email"] -# __license__ = _metadata["license"] -# __url__ = _metadata["url"] -# __version__ = _metadata["version"] +_metadata = metadata.extract_metadata() +__author__ = _metadata["author"] +__author_email__ = _metadata["email"] +__license__ = _metadata["license"] +__url__ = _metadata["url"] +__version__ = _metadata["version"] diff --git a/vault_cli/__main__.py b/vault_cli/__main__.py old mode 100644 new mode 100755 diff --git a/vault_cli/client.py b/vault_cli/client.py index 1092c8f..2966936 100644 --- a/vault_cli/client.py +++ b/vault_cli/client.py @@ -184,6 +184,7 @@ def _setup_kv_v2_methods(self, mount_point): "write": self.client.secrets.kv.v2.create_or_update_secret, "delete": self.client.secrets.kv.v2.delete_metadata_and_all_versions, "list": self.client.secrets.kv.v2.list_secrets, + "_version": 2 } def _setup_kv_v1_methods(self, mount_point): @@ -192,6 +193,7 @@ def _setup_kv_v1_methods(self, mount_point): "write": self.client.secrets.kv.v1.create_or_update_secret, "delete": self.client.secrets.kv.v1.delete_secret, "list": self.client.secrets.kv.v1.list_secrets, + "_version": 1 } @handle_errors() @@ -796,9 +798,12 @@ def _get_secret(self, path: str) -> Dict[str, types.JSONValue]: except hvac.exceptions.InvalidPath: # 404 No Secret found raise exceptions.VaultSecretNotFound( errors=[f"Secret not found at path '{path}'"] - ) - return secret["data"] - + ) + if self.vault_methods["_version"] == 1: + return secret["data"] + elif self.vault_methods["_version"] == 2: + return secret["data"]["data"] + raise exceptions.VaultInvalidRequest() @handle_errors() def _delete_secret(self, path: str) -> None: mount_point, path = self._extract_mount_secret_path(path)