Compare commits

...

12 Commits

Author SHA1 Message Date
4140b85115 Release v1.3.0
All checks were successful
CI / Checks (push) Successful in 1m11s
2025-10-24 11:13:32 +00:00
5c46d86788 v1.3.0 2025-10-24 11:12:23 +00:00
e5a3f3a3e1 release -> development 2025-10-24 10:48:39 +00:00
bb5d88229a Release v1.2.2
All checks were successful
CI / Checks (push) Successful in 1m49s
2024-06-05 07:14:39 +00:00
7975776398 v1.2.2 2024-06-05 07:10:16 +00:00
9c8f1388ce release -> development 2024-05-29 19:53:25 +00:00
4da8562871 Release v1.2.1
All checks were successful
CD / Checks (push) Successful in 45s
2024-05-29 19:45:22 +00:00
009d9b15e0 v1.2.1 2024-05-29 21:43:59 +02:00
56f5acfff8 Pull request #7: release -> development
Merge in PLAYG/keep-it-secret from release to development
2024-05-29 19:26:30 +00:00
a32c2743de Release v1.2.0 2024-02-08 20:54:58 +00:00
c17775b715 v1.2.0 2024-02-08 20:52:08 +00:00
4ba36b3a87 release -> development 2024-01-18 21:49:03 +01:00
18 changed files with 951 additions and 34 deletions

27
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,27 @@
name: "CI"
on:
push:
branches:
- "release"
jobs:
run-checks:
name: "Checks"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/setup-python@v5"
with:
python-version: "3.10"
- uses: "Gr1N/setup-poetry@v8"
with:
poetry-version: "1.7.1"
- name: "Install deps"
run: |
set -x
poetry install
- name: "Run CI task"
run: |
set -x
poetry run inv ci

View File

@ -1,5 +1,22 @@
## 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.
#### v1.2.1 (2024-05-29)
* Gitea Actions integration.
* `README.md` language fixes.
#### v1.2.0 (2024-02-08)
* Hashicorp Vault integration.
#### v1.1.0 (2024-01-18)
* `Secrets.resolve_dependency()` API for resolving field dependencies.

88
Dockerfile Normal file
View File

@ -0,0 +1,88 @@
ARG APP_USER_UID=10001
ARG APP_USER_GID=10001
ARG IMAGE_TAG=development.000000
FROM python:3.10.14-slim-bookworm AS base
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_TAG
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
PIP_INDEX_URL="https://nexus.bthlabs.pl/repository/pypi/simple/" \
POETRY_VERSION=1.7.1 \
POETRY_HOME="/srv/poetry" \
POETRY_NO_INTERACTION=1 \
VIRTUAL_ENV="/srv/venv" \
KEEP_IT_SECRET_IMAGE_TAG=${IMAGE_TAG}
RUN if [ ! $(getent group ${APP_USER_GID}) ];then groupadd -g ${APP_USER_GID} app; fi && \
useradd -m -d /home/app -u ${APP_USER_UID} -g ${APP_USER_GID} app && \
apt-get update && \
apt-get install -y --no-install-recommends wait-for-it dumb-init curl && \
(curl -sSL https://install.python-poetry.org | python -) && \
python3.10 -m venv ${VIRTUAL_ENV} && \
mkdir /srv/app /srv/bin /srv/lib /srv/log /srv/run && \
chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
ENV PATH="${VIRTUAL_ENV}/bin:/srv/bin:/srv/poetry/bin:${PATH}"
USER app
WORKDIR /srv/app
FROM base AS development
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_TAG
USER app
WORKDIR /srv/app
FROM development AS deployment-build
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_TAG
ADD --chown=$APP_USER_UID:$APP_USER_GID . /srv/app
RUN poetry install --no-dev
FROM deployment-build AS ci
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_TAG
RUN poetry install
FROM base AS deployment
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_TAG
COPY --from=deployment-build /srv/app /srv/app
COPY --from=deployment-build /srv/venv /srv/venv
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv
USER root
RUN apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/apt /var/lib/dpkg && \
rm -rf /home/app/.cache
USER app
ENV PYTHONPATH="/srv/app"
ENV DJANGO_SETTINGS_MODULE="settings"
EXPOSE 8000
ENTRYPOINT ["/usr/bin/dumb-init"]
CMD ["echo NOOP"]

View File

@ -65,7 +65,7 @@ to provide secrets suitable for the development environment:
```
The `ProductionSecrets` class uses environment variables and AWS Secrets
Manager to provide secrets suitable for the development environment:
Manager to provide secrets suitable for the production environment:
```
>>> production_secrets = ProductionSecrets()

View File

@ -11,10 +11,10 @@
project = 'Keep It Secret'
copyright = '2023-present Tomek Wójcik'
author = 'Tomek Wójcik'
version = '1.1.0'
version = '1.3.0'
# The full version, including alpha/beta/rc tags
release = '1.1.0'
release = '1.3.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -25,6 +25,32 @@ to be installed:
.. autoclass:: keep_it_secret.ext.aws.AWSSecretsManagerField
:members:
Hashicorp Vault Wrapper
-----------------------
**Installation**
Since Vault extension has external dependencies it needs to be explicitly named
to be installed:
.. code-block:: shell
$ pip install keep_it_secret[vault]
**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:
Basic secrets loader
--------------------

View File

@ -6,7 +6,7 @@ from .fields import ( # noqa: F401
)
from .secrets import Secrets # noqa: F401
__version__ = '1.1.0'
__version__ = '1.3.0'
__all__ = [
'AbstractField',

220
keep_it_secret/ext/vault.py Normal file
View File

@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
import hvac
from keep_it_secret.fields import EnvField, Field
from keep_it_secret.secrets import Secrets
class BaseVaultSecrets(Secrets):
"""
Base :py:class:`keep_it_secret.Secrets` subclass for Vault-base secrets.
"""
url: str = EnvField.new('VAULT_URL', required=True)
"""
Maps ``VAULT_URL`` environment variable.
:type: ``str``
"""
client_cert_path: str | None = EnvField.new('VAULT_CLIENT_CERT_PATH', required=False)
"""
Maps ``VAULT_CLIENT_CERT_PATH`` environment variable.
:type: ``str | None``
"""
client_key_path: str | None = EnvField.new('VAULT_CLIENT_KEY_PATH', required=False)
"""
Maps ``VAULT_CLIENT_KEY_PATH`` environment variable.
:type: ``str | None``
"""
server_cert_path: str | None = EnvField.new('VAULT_SERVER_CERT_PATH', required=False)
"""
Maps ``VAULT_SERVER_CERT_PATH`` environment variable.
:type: ``str | None``
"""
def __init__(self, parent: Secrets | None = None):
super().__init__(parent)
self.client: hvac.Client | None = None
def as_hvac_client_kwargs(self) -> dict[str, typing.Any]:
"""
Return representation of the mapped variables for use in
``hvac.Client`` constructor.
"""
result: dict[str, typing.Any] = {
'url': self.url,
}
if self.client_cert_path is not None and self.client_key_path is not None:
result['cert'] = (self.client_cert_path, self.client_key_path)
if self.server_cert_path is not None:
result['verify'] = self.server_cert_path
return result
def get_client(self) -> hvac.Client:
"""
Return the ``hvac.Client`` instance configured using the credentials.
"""
if self.client is None:
self.client = hvac.Client(
**self.as_hvac_client_kwargs(),
)
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
Vault KV V2 secrets engine to resolve the value.
If ``as_type`` isn't provided, the fetched value will be returned as-is (
i.e. as a dict). Otherwise, ``as_type`` should be a type which accepts
kwargs representing the value's keys in its constructor.
:param mount_point: Mount path for the secret engine.
:param path: Path to the secret to fetch.
:param version: Version identifier. Defaults to ``None`` (aka the newest
version).
:param default: Default value. Defaults to ``None``.
"""
def __init__(self,
mount_point: str,
path: str,
version: str | None = None,
default: typing.Any = None,
**field_options):
super().__init__(**field_options)
as_type: type | None = field_options.pop('as_type', None)
self.as_type = self.cast if as_type is not None else None # type: ignore[assignment]
self.mount_point = mount_point
self.path = path
self.version = version
self.default = default
self.cast_to = as_type
@classmethod
def new(cls: type[VaultKV2Field], # type: ignore[override]
mount_point: str,
path: str,
version: str | None = None,
default: typing.Any = None,
**field_options):
return cls(mount_point, path, version=version, default=default, **field_options)
def cast(self, data: typing.Any) -> typing.Any:
"""
Cast ``data`` to the type specified in ``as_type`` argument of the
constructor.
"""
if isinstance(data, dict) is False:
return data
if self.cast_to is None:
return data
return self.cast_to(**data)
def get_value(self, secrets: Secrets) -> typing.Any:
"""
Retrieve, decode and return the secret stored in a KV V2 secrets engine
mounted at *mount_path* under the path *path*.
Depends on :py:class:`VaultSecrets` to be declared in ``vault`` field
on ``secrets`` or one of its parents.
:raises DependencyMissing: Signal that ``secrets.aws`` field is
missing.
:raises RequiredValueMissing: Signal the field's value is required but
*secret_id* is not present in the secrets engine.
"""
vault_secrets: VaultSecrets = secrets.resolve_dependency(
'vault', include_parents=True,
)
if vault_secrets is secrets.UNRESOLVED_DEPENDENCY:
raise self.DependencyMissing('vault')
client = vault_secrets.get_client()
try:
secret_response = client.secrets.kv.v2.read_secret_version(
self.path,
version=self.version,
mount_point=self.mount_point,
raise_on_deleted_version=True,
)
return secret_response['data']['data']
except hvac.exceptions.InvalidPath as exception:
if self.required is True:
raise self.RequiredValueMissing(f'{self.mount_point}{self.path}') from exception
else:
return self.default

69
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.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"
@ -429,6 +429,23 @@ pygments = ">=2.7"
sphinx = ">=6.0,<8.0"
sphinx-basic-ng = "*"
[[package]]
name = "hvac"
version = "2.1.0"
description = "HashiCorp Vault API client"
optional = false
python-versions = ">=3.8,<4.0"
files = [
{file = "hvac-2.1.0-py3-none-any.whl", hash = "sha256:73bc91e58c3fc7c6b8107cdaca9cb71fa0a893dfd80ffbc1c14e20f24c0c29d7"},
{file = "hvac-2.1.0.tar.gz", hash = "sha256:b48bcda11a4ab0a7b6c47232c7ba7c87fda318ae2d4a7662800c465a78742894"},
]
[package.dependencies]
requests = ">=2.27.1,<3.0.0"
[package.extras]
parser = ["pyhcl (>=0.4.4,<0.5.0)"]
[[package]]
name = "idna"
version = "3.6"
@ -483,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]]
@ -1029,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]]
@ -1051,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]]
@ -1182,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"},
@ -1692,8 +1710,9 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[extras]
aws = ["boto3"]
vault = ["hvac"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "e0cabda70067d490af2f9c9e9251384fb43454c545da6a071e25b1b74cc9f951"
content-hash = "1547e872ed842c8e1ea41e45841bf25a327cbaa907e075613c9b12077d2ffefc"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "keep-it-secret"
version = "1.1.0"
version = "1.3.0"
description = "Keep It Secret by BTHLabs"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
maintainers = ["BTHLabs <contact@bthlabs.pl>"]
@ -13,13 +13,15 @@ documentation = "https://projects.bthlabs.pl/keep-it-secret/"
[tool.poetry.dependencies]
python = "^3.10"
boto3 = {version = ">=1.34.0", optional = true}
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"
invoke = "1.7.3"
hvac = "2.1.0"
invoke = "2.2.1"
ipdb = "0.13.13"
ipython = "8.19.0"
moto = "4.2.12"
@ -32,6 +34,7 @@ twine = "4.0.2"
[tool.poetry.extras]
aws = ["boto3"]
vault = ["hvac"]
[build-system]
requires = ["poetry-core"]

View File

@ -6,12 +6,15 @@ hang-closing = False
[tool:pytest]
addopts = --disable-warnings
junit_suite_name = keep_it_secret
env =
KEEP_IT_SECRET_TESTS_SPAM=spam
AWS_ACCESS_KEY_ID=thisisntright
AWS_SECRET_ACCESS_KEY=thisisntright
AWS_SESSION_TOKEN=thisisntright
AWS_DEFAULT_REGION=eu-central-1
VAULT_URL=http://thisisntright:8200/
VAULT_TOKEN=thisisntright
[mypy]
@ -21,5 +24,8 @@ ignore_missing_imports = True
[mypy-boto3.*]
ignore_missing_imports = True
[mypy-hvac.*]
ignore_missing_imports = True
[mypy-moto.*]
ignore_missing_imports = True

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# type: ignore
import os
from invoke import task
try:
@ -20,13 +22,25 @@ def mypy(ctx, warn=False):
@task
def tests(ctx, warn=False):
return ctx.run('pytest -v', warn=warn)
pytest_command_line = [
'pytest',
'-v',
]
if 'KEEP_IT_SECRET_JUNIT_XML_PATH' in os.environ:
pytest_command_line.append(
f"--junit-xml={os.environ['KEEP_IT_SECRET_JUNIT_XML_PATH']}",
)
return ctx.run(' '.join(pytest_command_line), warn=warn)
@task
def ci(ctx):
result = True
ctx.run('mkdir -p build')
if flake8(ctx, warn=True).exited != 0:
result = False

View File

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# 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()

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
# -*- coding: utf-8 -*-
from __future__ import annotations
from keep_it_secret.ext.vault import VaultSecrets
from keep_it_secret.fields import SecretsField
from keep_it_secret.secrets import Secrets
class TestingVaultSecrets(Secrets):
vault = SecretsField.new(VaultSecrets)

View File

@ -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()

View File

@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock
import hvac
import pytest
from pytest_mock import MockerFixture
from keep_it_secret import Secrets
from keep_it_secret.ext import vault
@pytest.fixture
def field() -> vault.VaultKV2Field:
return vault.VaultKV2Field('keep_it_secret/', 'tests/spam')
@pytest.fixture
def as_type() -> mock.Mock:
return mock.Mock()
@pytest.fixture
def field_with_as_type(as_type: mock.Mock) -> vault.VaultKV2Field:
return vault.VaultKV2Field('keep_it_secret/', 'tests/spam', as_type=as_type)
@pytest.fixture
def hvac_kv_v2_client(mocker: MockerFixture) -> mock.Mock:
result = mock.Mock()
result.secrets.kv.v2 = mock.Mock(spec=['read_secret_version'])
return result
def test_init():
# When
result = vault.VaultKV2Field('keep_it_secret/', 'tests/spam')
# Then
assert result.mount_point == 'keep_it_secret/'
assert result.path == 'tests/spam'
assert result.version is None
assert result.default is None
assert result.cast_to is None
assert result.as_type is None
def test_init_with_version():
# When
result = vault.VaultKV2Field(
'keep_it_secret/', 'tests/spam', version='1',
)
# Then
assert result.version == '1'
def test_init_with_default():
# When
result = vault.VaultKV2Field(
'keep_it_secret/', 'tests/spam', default='eggs',
)
# Then
assert result.default == 'eggs'
def test_init_with_as_type():
# When
result = vault.VaultKV2Field(
'keep_it_secret/', 'tests/spam', as_type=dict,
)
# Then
assert result.as_type == result.cast
assert result.cast_to == dict
def test_init_with_field_options():
# When
result = vault.VaultKV2Field(
'keep_it_secret/', 'tests/spam', required=False, description='eggs',
)
# Then
assert result.required is False
assert result.description == 'eggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
vault.VaultKV2Field, '__init__', return_value=None,
)
# When
_ = vault.VaultKV2Field.new(
'keep_it_secret/',
'tests/spam',
version='1',
default=dict,
as_type=dict,
required=False,
description='eggs',
)
# Then
mock_init.assert_called_once_with(
'keep_it_secret/',
'tests/spam',
version='1',
default=dict,
as_type=dict,
required=False,
description='eggs',
)
def test_cast_not_dict(field: vault.VaultKV2Field):
# When
result = field.cast('spam')
# Then
assert result == 'spam'
def test_cast(field_with_as_type: vault.VaultKV2Field, as_type: mock.Mock):
# Given
as_type.return_value = 'spam'
# When
result = field_with_as_type.cast({
'spam': True,
'eggs': False,
})
# Then
assert result == 'spam'
as_type.assert_called_once_with(
spam=True,
eggs=False,
)
def test_get_value_vault_dependency_missing(field: vault.VaultKV2Field,
testing_secrets: Secrets):
# Given
with pytest.raises(field.DependencyMissing) as exception_info:
# When
_ = field(testing_secrets)
# Then
assert exception_info.value.args[0] == 'vault'
def test_get_value_required_value_not_found(testing_vault_secrets: Secrets,
hvac_kv_v2_client: mock.Mock,
field: vault.VaultKV2Field):
# Given
testing_vault_secrets.vault.client = hvac_kv_v2_client
hvac_kv_v2_client.secrets.kv.v2.read_secret_version.side_effect = hvac.exceptions.InvalidPath()
with pytest.raises(field.RequiredValueMissing) as exception_info:
# When
_ = field(testing_vault_secrets)
# Then
assert exception_info.value.args[0] == 'keep_it_secret/tests/spam'
def test_get_value_not_found_not_required(testing_vault_secrets: Secrets,
hvac_kv_v2_client: mock.Mock,
field: vault.VaultKV2Field):
# Given
testing_vault_secrets.vault.client = hvac_kv_v2_client
hvac_kv_v2_client.secrets.kv.v2.read_secret_version.side_effect = hvac.exceptions.InvalidPath()
field = vault.VaultKV2Field(
'keep_it_secret/', 'tests/spam', required=False,
)
result = field(testing_vault_secrets)
# Then
assert result is None
def test_get_value(testing_vault_secrets: Secrets,
hvac_kv_v2_client: mock.Mock,
field: vault.VaultKV2Field):
# Given
testing_vault_secrets.vault.client = hvac_kv_v2_client
hvac_kv_v2_client.secrets.kv.v2.read_secret_version.return_value = {
'data': {
'data': {
'spam': True,
},
},
}
# When
result = field(testing_vault_secrets)
# Then
assert result == {'spam': True}

View File

@ -0,0 +1,117 @@
# -*- 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_TOKEN': 'test_vault_token',
},
)
def test_init():
# When
result = vault.VaultSecrets()
# Then
assert result.client is None
@mock.patch.dict(
os.environ,
{
'VAULT_URL': 'https://vault.work/',
'VAULT_TOKEN': 'test_vault_token',
'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.VaultSecrets()
# When
result = secrets.as_hvac_client_kwargs()
# Then
assert result == {
'url': 'https://vault.work/',
'token': 'test_vault_token',
'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_TOKEN': 'test_vault_token',
},
)
def test_as_hvac_client_kwargs_without_optional_fields():
# Given
secrets = vault.VaultSecrets()
# When
result = secrets.as_hvac_client_kwargs()
# Then
assert result == {
'url': 'https://vault.work/',
'token': 'test_vault_token',
}
@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
mock_hvac_client.return_value = hvac_client
secrets = vault.VaultSecrets()
# 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())
@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
secrets = vault.VaultSecrets()
secrets.client = hvac_client
# When
result = secrets.get_client()
# Then
assert result == hvac_client
mock_hvac_client.assert_not_called()