Skip to content

Commit c00b2c7

Browse files
authored
Merge pull request #84 from tr11/feature/vault
Added support for Hashicorp Vault (kv2)
2 parents 5751cfc + 4da879d commit c00b2c7

File tree

10 files changed

+399
-84
lines changed

10 files changed

+399
-84
lines changed

.github/workflows/run_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
virtualenvs-in-project: true
2626

2727
- name: Install dependencies
28-
run: poetry install -v -E toml -E yaml -E azure -E aws -E gcp
28+
run: poetry install -v -E toml -E yaml -E azure -E aws -E gcp -E vault
2929

3030
- name: Run pytest
3131
run: poetry run pytest --cov=./ --cov-report=xml

config/contrib/aws.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Configuration from AWS Secrets Manager."""
1+
"""Configuration instances from AWS Secrets Manager."""
22

33
import json
44
import time

config/contrib/azure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Configuration from Azure KeyVaults."""
1+
"""Configuration instances from Azure KeyVaults."""
22

33
import time
44
from typing import Any, Dict, ItemsView, KeysView, Optional, Union, ValuesView, cast
@@ -88,7 +88,7 @@ def __getitem__(self, item: str) -> Any: # noqa: D105
8888
def __getattr__(self, item: str) -> Any: # noqa: D105
8989
secret = self._get_secret(item)
9090
if secret is None:
91-
raise KeyError(item)
91+
raise AttributeError(item)
9292
else:
9393
return secret
9494

config/contrib/gcp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Configuration from GCP Secret Manager."""
1+
"""Configuration instances from GCP Secret Manager."""
22

33
import time
44
from typing import Any, Dict, ItemsView, KeysView, Optional, Union, ValuesView, cast
@@ -89,7 +89,7 @@ def __getitem__(self, item: str) -> Any: # noqa: D105
8989
def __getattr__(self, item: str) -> Any: # noqa: D105
9090
secret = self._get_secret(item)
9191
if secret is None:
92-
raise KeyError(item)
92+
raise AttributeError(item)
9393
else:
9494
return secret
9595

config/contrib/vault.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Configuration instances from Hashicorp Vault."""
2+
3+
import time
4+
from typing import (
5+
Any,
6+
Dict,
7+
ItemsView,
8+
KeysView,
9+
Mapping,
10+
Optional,
11+
Union,
12+
ValuesView,
13+
cast,
14+
)
15+
16+
import hvac
17+
from hvac.exceptions import InvalidPath
18+
19+
20+
from .. import Configuration, InterpolateType, config_from_dict
21+
22+
23+
class Cache:
24+
"""Cache class."""
25+
26+
def __init__(self, value: Dict[str, Any], ts: float): # noqa: D107
27+
self.value = value
28+
self.ts = ts
29+
30+
31+
class HashicorpVaultConfiguration(Configuration):
32+
"""
33+
Hashicorp Vault Configuration class.
34+
35+
The Hashicorp Vault Configuration class takes Vault credentials and
36+
behaves like a drop-in replacement for the regular Configuration class.
37+
38+
The following limitations apply to the Hashicorp Vault Configurations:
39+
- only works with KV version 2
40+
- only supports the latest secret version
41+
- assumes that secrets are named as <engine name>/<path>/<field>
42+
"""
43+
44+
def __init__(
45+
self,
46+
engine: str,
47+
cache_expiration: int = 5 * 60,
48+
interpolate: InterpolateType = False,
49+
**kwargs: Mapping[str, Any],
50+
) -> None:
51+
"""
52+
Constructor.
53+
54+
See https://developer.hashicorp.com/vault/docs/get-started/developer-qs.
55+
""" # noqa: E501
56+
self._client = hvac.Client(**kwargs)
57+
self._cache_expiration = cache_expiration
58+
self._cache: Dict[str, Cache] = {}
59+
self._engine = engine
60+
self._interpolate = {} if interpolate is True else interpolate
61+
self._default_levels = None
62+
63+
def _get_secret(self, secret: str) -> Optional[Dict[str, Any]]:
64+
now = time.time()
65+
from_cache = self._cache.get(secret)
66+
if from_cache and from_cache.ts + self._cache_expiration > now:
67+
return from_cache.value
68+
try:
69+
data = cast(
70+
Dict[str, Any],
71+
self._client.kv.v2.read_secret(secret, mount_point=self._engine)[
72+
"data"
73+
]["data"],
74+
)
75+
self._cache[secret] = Cache(value=data, ts=now)
76+
return data
77+
except (InvalidPath, KeyError):
78+
if secret in self._cache:
79+
del self._cache[secret]
80+
return None
81+
82+
def __getitem__(self, item: str) -> Any: # noqa: D105
83+
path, *rest = item.split(".", 1)
84+
secret = self._get_secret(path)
85+
if secret is None:
86+
raise KeyError(item)
87+
else:
88+
return (
89+
Configuration(secret)[".".join(rest)] if rest else Configuration(secret)
90+
)
91+
92+
def __getattr__(self, item: str) -> Any: # noqa: D105
93+
secret = self._get_secret(item)
94+
if secret is None:
95+
raise AttributeError(item)
96+
else:
97+
return Configuration(secret)
98+
99+
def get(self, key: str, default: Any = None) -> Union[dict, Any]:
100+
"""
101+
Get the configuration values corresponding to :attr:`key`.
102+
103+
:param key: key to retrieve
104+
:param default: default value in case the key is missing
105+
:return: the value found or a default
106+
"""
107+
try:
108+
return self[key]
109+
except KeyError:
110+
return default
111+
112+
def keys(
113+
self, levels: Optional[int] = None
114+
) -> Union["Configuration", Any, KeysView[str]]:
115+
"""Return a set-like object providing a view on the configuration keys."""
116+
assert not levels # Vault secrets don't support separators
117+
return cast(
118+
KeysView[str],
119+
self._client.list(f"/{self._engine}/metadata")["data"]["keys"],
120+
)
121+
122+
def values(
123+
self, levels: Optional[int] = None
124+
) -> Union["Configuration", Any, ValuesView[Any]]:
125+
"""Return a set-like object providing a view on the configuration values."""
126+
assert not levels # GCP Secret Manager secrets don't support separators
127+
return cast(
128+
ValuesView[str],
129+
(
130+
self._get_secret(k)
131+
for k in self._client.list(f"/{self._engine}/metadata")["data"]["keys"]
132+
),
133+
)
134+
135+
def items(
136+
self, levels: Optional[int] = None
137+
) -> Union["Configuration", Any, ItemsView[str, Any]]:
138+
"""Return a set-like object providing a view on the configuration items."""
139+
assert not levels # GCP Secret Manager secrets don't support separators
140+
return cast(
141+
ItemsView[str, Any],
142+
(
143+
(k, self._get_secret(k))
144+
for k in self._client.list(f"/{self._engine}/metadata")["data"]["keys"]
145+
),
146+
)
147+
148+
def reload(self) -> None:
149+
"""Reload the configuration."""
150+
self._cache.clear()
151+
152+
def __repr__(self) -> str: # noqa: D105
153+
return "<HashicorpVaultConfiguration: %r>" % self._engine
154+
155+
@property
156+
def _config(self) -> Dict[str, Any]: # type: ignore
157+
return config_from_dict(dict(self.items()))._config

pyproject.toml

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ azure-identity = { version = "^1.13.0", optional = true }
1717
azure-keyvault = { version = "^4.2.0", optional = true }
1818
boto3 = { version = "^1.28.20", optional = true }
1919
google-cloud-secret-manager = { version = "^2.16.3", optional = true }
20+
hvac = { version ="^1.1.1", optional = true }
2021
pyyaml = { version = "^6.0", optional = true }
2122
toml = { version = "^0.10.0", optional = true }
2223

@@ -44,6 +45,7 @@ aws = ["boto3"]
4445
azure = ["azure-keyvault", "azure-identity"]
4546
gcp = ["google-cloud-secret-manager"]
4647
toml = ["toml"]
48+
vault = ["hvac"]
4749
yaml = ["pyyaml"]
4850

4951
[tool.black]
@@ -58,10 +60,72 @@ envlist = py38, py39, py310, py311
5860
[testenv]
5961
allowlist_externals = poetry
6062
commands =
61-
poetry install -v -E toml -E yaml -E azure -E aws -E gcp
63+
poetry install -v -E toml -E yaml -E azure -E aws -E gcp -E vault
6264
poetry run pytest
6365
"""
6466

67+
[tool.mypy]
68+
warn_return_any = true
69+
warn_unused_configs = true
70+
disallow_untyped_calls = true
71+
disallow_untyped_defs = true
72+
disallow_subclassing_any = true
73+
disallow_any_decorated = true
74+
disallow_incomplete_defs = true
75+
disallow_untyped_decorators = true
76+
no_implicit_optional = true
77+
warn_unused_ignores = true
78+
warn_redundant_casts = true
79+
exclude = [
80+
'tests'
81+
]
82+
83+
[[tool.mypy.overrides]]
84+
module= [
85+
'google.auth.credentials',
86+
'yaml',
87+
'toml',
88+
'boto3',
89+
'botocore.exceptions',
90+
'hvac',
91+
'hvac.exceptions',
92+
]
93+
ignore_missing_imports = true
94+
95+
[tool.coverage.run]
96+
branch = true
97+
include = [
98+
'config/*'
99+
]
100+
101+
[tool.coverage.html]
102+
directory = 'cover'
103+
104+
[tool.pytest.ini_options]
105+
minversion = "6.0"
106+
addopts = '--cov --cov-report=html --cov-report term-missing --flake8 --mypy --black'
107+
flake8-max-line-length = 88
108+
flake8-extensions =[
109+
'flake8-docstrings',
110+
'flake8-comprehensions',
111+
'flake8-import-order',
112+
'flake8-bugbear',
113+
'flake8-blind-except',
114+
'flake8-builtins',
115+
'flake8-logging-format',
116+
'flake8-black'
117+
]
118+
flake8-ignore = [
119+
'* E203',
120+
'tests/* ALL',
121+
'docs/* ALL'
122+
]
123+
filterwarnings =[
124+
'ignore::pytest.PytestDeprecationWarning',
125+
'ignore::DeprecationWarning',
126+
'ignore::pytest.PytestWarning'
127+
]
128+
65129
[build-system]
66130
build-backend = "poetry.masonry.api"
67131
requires = ["poetry>=1.5.0"]

setup.cfg

Lines changed: 0 additions & 70 deletions
This file was deleted.

tests/contrib/test_azure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ def test_get_attr(): # type: ignore
124124

125125
assert cfg.foo == "foo_val"
126126

127-
with raises(KeyError):
128-
assert cfg.foo_missing is KeyError
127+
with raises(AttributeError):
128+
assert cfg.foo_missing is AttributeError
129129

130130

131131
@pytest.mark.skipif("azure is None")

tests/contrib/test_gcp.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@ def call(*args: list, **kwargs: dict) -> FakeSecretClient:
5757
def test_load_dict(): # type: ignore
5858
secretmanager_v1.SecretManagerServiceClient = fake_client(DICT)
5959
cfg = GCPSecretManagerConfiguration("fake_id")
60-
61-
print(cfg)
62-
6360
assert cfg["foo"] == "foo_val"
6461
assert cfg["with_underscore"] == "works"
6562
assert cfg.get("foo", "default") == "foo_val"
@@ -120,8 +117,8 @@ def test_get_attr(): # type: ignore
120117

121118
assert cfg.foo == "foo_val"
122119

123-
with raises(KeyError):
124-
assert cfg.foo_missing is KeyError
120+
with raises(AttributeError):
121+
assert cfg.foo_missing is AttributeError
125122

126123

127124
@pytest.mark.skipif("secretmanager_v1 is None")

0 commit comments

Comments
 (0)