From 5c46d86788f7311ff1a8cae9b1ca175381bfa27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomek=20W=C3=B3jcik?= Date: Fri, 24 Oct 2025 11:12:23 +0000 Subject: [PATCH] v1.3.0 --- CHANGELOG.md | 4 + docs/source/conf.py | 4 +- docs/source/ext.rst | 6 + keep_it_secret/__init__.py | 2 +- keep_it_secret/ext/vault.py | 65 ++++++++-- poetry.lock | 51 ++++---- pyproject.toml | 8 +- tests/ext/vault/conftest.py | 19 +++ tests/ext/vault/test_AppRoleVaultSecrets.py | 124 ++++++++++++++++++++ tests/ext/vault/test_VaultSecrets.py | 34 ++++-- 10 files changed, 261 insertions(+), 56 deletions(-) create mode 100644 tests/ext/vault/test_AppRoleVaultSecrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa1535..b80221a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Keep It Secret Changelog +#### v1.3.0 (2024-10-24) + +* Support for approle auth in Hashicorp Vault integration. + #### v1.2.2 (2024-06-05) * TeamCity integration for private builds. diff --git a/docs/source/conf.py b/docs/source/conf.py index 0b88459..ec67bd9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,10 +11,10 @@ project = 'Keep It Secret' copyright = '2023-present Tomek Wójcik' author = 'Tomek Wójcik' -version = '1.2.2' +version = '1.3.0' # The full version, including alpha/beta/rc tags -release = '1.2.2' +release = '1.3.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 331360b..32121d9 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -39,9 +39,15 @@ to be installed: **API** +.. autoclass:: keep_it_secret.ext.vault.BaseVaultSecrets + :members: + .. autoclass:: keep_it_secret.ext.vault.VaultSecrets :members: +.. autoclass:: keep_it_secret.ext.vault.AppRoleVaultSecrets + :members: + .. autoclass:: keep_it_secret.ext.vault.VaultKV2Field :members: diff --git a/keep_it_secret/__init__.py b/keep_it_secret/__init__.py index 2573270..4620536 100644 --- a/keep_it_secret/__init__.py +++ b/keep_it_secret/__init__.py @@ -6,7 +6,7 @@ from .fields import ( # noqa: F401 ) from .secrets import Secrets # noqa: F401 -__version__ = '1.2.2' +__version__ = '1.3.0' __all__ = [ 'AbstractField', diff --git a/keep_it_secret/ext/vault.py b/keep_it_secret/ext/vault.py index f6e3b82..4e8e77e 100644 --- a/keep_it_secret/ext/vault.py +++ b/keep_it_secret/ext/vault.py @@ -9,10 +9,9 @@ from keep_it_secret.fields import EnvField, Field from keep_it_secret.secrets import Secrets -class VaultSecrets(Secrets): +class BaseVaultSecrets(Secrets): """ - Concrete :py:class:`keep_it_secret.Secrets` subclass that maps environment - variables to Vault credentials. + Base :py:class:`keep_it_secret.Secrets` subclass for Vault-base secrets. """ url: str = EnvField.new('VAULT_URL', required=True) @@ -22,13 +21,6 @@ class VaultSecrets(Secrets): :type: ``str`` """ - token: str = EnvField.new('VAULT_TOKEN', required=True) - """ - Maps ``VAULT_TOKEN`` environment variable. - - :type: ``str`` - """ - client_cert_path: str | None = EnvField.new('VAULT_CLIENT_CERT_PATH', required=False) """ Maps ``VAULT_CLIENT_CERT_PATH`` environment variable. @@ -62,7 +54,6 @@ class VaultSecrets(Secrets): """ result: dict[str, typing.Any] = { 'url': self.url, - 'token': self.token, } if self.client_cert_path is not None and self.client_key_path is not None: @@ -85,6 +76,58 @@ class VaultSecrets(Secrets): return self.client +class VaultSecrets(BaseVaultSecrets): + """ + Concrete :py:class:`BaseVaultSecrets` subclass that uses token to + authenticate with Vault. + """ + + token: str = EnvField.new('VAULT_TOKEN', required=True) + """ + Maps ``VAULT_TOKEN`` environment variable. + + :type: ``str`` + """ + + def as_hvac_client_kwargs(self) -> dict[str, typing.Any]: + result = super().as_hvac_client_kwargs() + result['token'] = self.token + + return result + + +class AppRoleVaultSecrets(BaseVaultSecrets): + """ + Concrete :py:class:`BaseVaultSecrets` subclass that uses app role to + authenticate with Vault. + """ + + role_id: str = EnvField.new('VAULT_ROLE_ID', required=True) + """ + Maps ``VAULT_ROLE_ID`` environment variable. + + :type: ``str`` + """ + + secret_id: str = EnvField.new('VAULT_SECRET_ID', required=True) + """ + Maps ``VAULT_SECRET_ID`` environment variable. + + :type: ``str`` + """ + + def get_client(self) -> hvac.Client: + if self.client is None: + super().get_client() + + self.client.auth.approle.login( # type: ignore[attr-defined] + role_id=self.role_id, + secret_id=self.secret_id, + ) + + return self.client + + class VaultKV2Field(Field): """ Concrete :py:class:`keep_it_secret.Field` subclass that uses Hashicorp diff --git a/poetry.lock b/poetry.lock index 5faf70e..400b713 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -384,33 +384,33 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth [[package]] name = "flake8" -version = "6.1.0" +version = "7.3.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false -python-versions = ">=3.8.1" +python-versions = ">=3.9" files = [ - {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, - {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.11.0,<2.12.0" -pyflakes = ">=3.1.0,<3.2.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" [[package]] name = "flake8-commas" -version = "2.1.0" +version = "4.0.0" description = "Flake8 lint for trailing commas." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "flake8-commas-2.1.0.tar.gz", hash = "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263"}, - {file = "flake8_commas-2.1.0-py2.py3-none-any.whl", hash = "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"}, + {file = "flake8_commas-4.0.0-py3-none-any.whl", hash = "sha256:cad476d71ba72e8b941a8508d5b9ffb6b03e50f7102982474f085ad0d674b685"}, + {file = "flake8_commas-4.0.0.tar.gz", hash = "sha256:a68834b42a9a31c94ca790efe557a932c0eae21a3479c6b9a23c4dc077e3ea96"}, ] [package.dependencies] -flake8 = ">=2" +flake8 = ">=5" [[package]] name = "furo" @@ -500,13 +500,13 @@ files = [ [[package]] name = "invoke" -version = "1.7.3" +version = "2.2.1" description = "Pythonic task execution" optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "invoke-1.7.3-py3-none-any.whl", hash = "sha256:d9694a865764dd3fd91f25f7e9a97fb41666e822bbb00e670091e3f43933574d"}, - {file = "invoke-1.7.3.tar.gz", hash = "sha256:41b428342d466a82135d5ab37119685a989713742be46e42a3a399d685579314"}, + {file = "invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8"}, + {file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"}, ] [[package]] @@ -1046,13 +1046,13 @@ tests = ["pytest"] [[package]] name = "pycodestyle" -version = "2.11.1" +version = "2.14.0" description = "Python style guide checker" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, - {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, ] [[package]] @@ -1068,13 +1068,13 @@ files = [ [[package]] name = "pyflakes" -version = "3.1.0" +version = "3.4.0" description = "passive checker of Python programs" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, - {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, ] [[package]] @@ -1199,6 +1199,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1714,4 +1715,4 @@ vault = ["hvac"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "e4959d95b206c17013e548f015fbf288d9bc754584bfc128458cadead137a863" +content-hash = "1547e872ed842c8e1ea41e45841bf25a327cbaa907e075613c9b12077d2ffefc" diff --git a/pyproject.toml b/pyproject.toml index a20c822..e11e42b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep-it-secret" -version = "1.2.2" +version = "1.3.0" description = "Keep It Secret by BTHLabs" authors = ["Tomek Wójcik "] maintainers = ["BTHLabs "] @@ -17,11 +17,11 @@ hvac = {version = ">=2.1.0", optional = true} [tool.poetry.group.dev.dependencies] boto3 = "1.34.8" -flake8 = "6.1.0" -flake8-commas = "2.1.0" +flake8 = "7.3.0" +flake8-commas = "4.0.0" furo = "2023.9.10" hvac = "2.1.0" -invoke = "1.7.3" +invoke = "2.2.1" ipdb = "0.13.13" ipython = "8.19.0" moto = "4.2.12" diff --git a/tests/ext/vault/conftest.py b/tests/ext/vault/conftest.py index aaec631..34ca024 100644 --- a/tests/ext/vault/conftest.py +++ b/tests/ext/vault/conftest.py @@ -2,11 +2,30 @@ # type: ignore from __future__ import annotations +from unittest import mock + +import hvac import pytest +from pytest_mock import MockerFixture from .fixtures import TestingVaultSecrets +@pytest.fixture +def hvac_client() -> mock.Mock: + return mock.Mock(spec=hvac.Client) + + +@pytest.fixture +def mock_hvac_client(mocker: MockerFixture, + hvac_client: mock.Mock, + ) -> mock.Mock: + return mocker.patch( + 'keep_it_secret.ext.vault.hvac.Client', + return_value=hvac_client, + ) + + @pytest.fixture def testing_vault_secrets() -> TestingVaultSecrets: return TestingVaultSecrets() diff --git a/tests/ext/vault/test_AppRoleVaultSecrets.py b/tests/ext/vault/test_AppRoleVaultSecrets.py new file mode 100644 index 0000000..f928977 --- /dev/null +++ b/tests/ext/vault/test_AppRoleVaultSecrets.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# type: ignore +from __future__ import annotations + +import os +from unittest import mock + +from keep_it_secret.ext import vault + + +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_ROLE_ID': 'test_role_id', + 'VAULT_SECRET_ID': 'test_secret_id', + }, +) +def test_init(): + # When + result = vault.AppRoleVaultSecrets() + + # Then + assert result.client is None + + +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_ROLE_ID': 'test_role_id', + 'VAULT_SECRET_ID': 'test_secret_id', + 'VAULT_CLIENT_CERT_PATH': '/tmp/vault_client_cert.pem', + 'VAULT_CLIENT_KEY_PATH': '/tmp/vault_client_key.pem', + 'VAULT_SERVER_CERT_PATH': '/tmp/vault_server_cert.pem', + }, +) +def test_as_hvac_client_kwargs(): + # Given + secrets = vault.AppRoleVaultSecrets() + + # When + result = secrets.as_hvac_client_kwargs() + + # Then + assert result == { + 'url': 'https://vault.work/', + 'cert': ('/tmp/vault_client_cert.pem', '/tmp/vault_client_key.pem'), + 'verify': '/tmp/vault_server_cert.pem', + } + + +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_ROLE_ID': 'test_role_id', + 'VAULT_SECRET_ID': 'test_secret_id', + }, +) +def test_as_hvac_client_kwargs_without_optional_fields(): + # Given + secrets = vault.AppRoleVaultSecrets() + + # When + result = secrets.as_hvac_client_kwargs() + + # Then + assert result == { + 'url': 'https://vault.work/', + } + + +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_ROLE_ID': 'test_role_id', + 'VAULT_SECRET_ID': 'test_secret_id', + }, +) +def test_get_client_cache_miss(mock_hvac_client: mock.Mock, + hvac_client: mock.Mock): + # Given + mock_hvac_client.return_value = hvac_client + + secrets = vault.AppRoleVaultSecrets() + + # When + result = secrets.get_client() + + # Then + assert result == hvac_client + + assert secrets.client == hvac_client + + mock_hvac_client.assert_called_once_with(**secrets.as_hvac_client_kwargs()) + hvac_client.auth.approle.login.assert_called_once_with( + role_id='test_role_id', + secret_id='test_secret_id', + ) + + +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_ROLE_ID': 'test_role_id', + 'VAULT_SECRET_ID': 'test_secret_id', + }, +) +def test_get_client_cache_hit(mock_hvac_client: mock.Mock, + hvac_client: mock.Mock): + # Given + secrets = vault.AppRoleVaultSecrets() + secrets.client = hvac_client + + # When + result = secrets.get_client() + + # Then + assert result == hvac_client + + mock_hvac_client.assert_not_called() diff --git a/tests/ext/vault/test_VaultSecrets.py b/tests/ext/vault/test_VaultSecrets.py index 3f81c3d..d4c3e94 100644 --- a/tests/ext/vault/test_VaultSecrets.py +++ b/tests/ext/vault/test_VaultSecrets.py @@ -5,22 +5,16 @@ from __future__ import annotations import os from unittest import mock -import pytest -from pytest_mock import MockerFixture - from keep_it_secret.ext import vault -@pytest.fixture -def mock_hvac_client(mocker: MockerFixture) -> mock.Mock: - return mocker.patch.object(vault.hvac, 'Client') - - -@pytest.fixture -def hvac_client() -> mock.Mock: - return mock.Mock() - - +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_TOKEN': 'test_vault_token', + }, +) def test_init(): # When result = vault.VaultSecrets() @@ -76,6 +70,13 @@ def test_as_hvac_client_kwargs_without_optional_fields(): } +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_TOKEN': 'test_vault_token', + }, +) def test_get_client_cache_miss(mock_hvac_client: mock.Mock, hvac_client: mock.Mock): # Given @@ -94,6 +95,13 @@ def test_get_client_cache_miss(mock_hvac_client: mock.Mock, mock_hvac_client.assert_called_once_with(**secrets.as_hvac_client_kwargs()) +@mock.patch.dict( + os.environ, + { + 'VAULT_URL': 'https://vault.work/', + 'VAULT_TOKEN': 'test_vault_token', + }, +) def test_get_client_cache_hit(mock_hvac_client: mock.Mock, hvac_client: mock.Mock): # Given