archipy-vault 5.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archipy_vault-5.11.0/.gitignore +245 -0
- archipy_vault-5.11.0/PKG-INFO +20 -0
- archipy_vault-5.11.0/README.md +7 -0
- archipy_vault-5.11.0/archipy/adapters/vault/__init__.py +1 -0
- archipy_vault-5.11.0/archipy/adapters/vault/adapters.py +502 -0
- archipy_vault-5.11.0/archipy/adapters/vault/ports.py +142 -0
- archipy_vault-5.11.0/pyproject.toml +26 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
### JetBrains template
|
|
2
|
+
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
|
3
|
+
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
|
4
|
+
.idea
|
|
5
|
+
# User-specific stuff
|
|
6
|
+
.idea/**/workspace.xml
|
|
7
|
+
.idea/**/tasks.xml
|
|
8
|
+
.idea/**/usage.statistics.xml
|
|
9
|
+
.idea/**/dictionaries
|
|
10
|
+
.idea/**/shelf
|
|
11
|
+
dist
|
|
12
|
+
# AWS User-specific
|
|
13
|
+
.idea/**/aws.xml
|
|
14
|
+
|
|
15
|
+
# Generated files
|
|
16
|
+
.idea/**/contentModel.xml
|
|
17
|
+
|
|
18
|
+
# Sensitive or high-churn files
|
|
19
|
+
.idea/**/dataSources/
|
|
20
|
+
.idea/**/dataSources.ids
|
|
21
|
+
.idea/**/dataSources.local.xml
|
|
22
|
+
.idea/**/sqlDataSources.xml
|
|
23
|
+
.idea/**/dynamic.xml
|
|
24
|
+
.idea/**/uiDesigner.xml
|
|
25
|
+
.idea/**/dbnavigator.xml
|
|
26
|
+
|
|
27
|
+
# Gradle
|
|
28
|
+
.idea/**/gradle.xml
|
|
29
|
+
.idea/**/libraries
|
|
30
|
+
|
|
31
|
+
# Gradle and Maven with auto-import
|
|
32
|
+
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
33
|
+
# since they will be recreated, and may cause churn. Uncomment if using
|
|
34
|
+
# auto-import.
|
|
35
|
+
# .idea/artifacts
|
|
36
|
+
# .idea/compiler.xml
|
|
37
|
+
# .idea/jarRepositories.xml
|
|
38
|
+
# .idea/modules.xml
|
|
39
|
+
# .idea/*.iml
|
|
40
|
+
# .idea/modules
|
|
41
|
+
# *.iml
|
|
42
|
+
# *.ipr
|
|
43
|
+
|
|
44
|
+
# CMake
|
|
45
|
+
cmake-build-*/
|
|
46
|
+
|
|
47
|
+
# Mongo Explorer plugin
|
|
48
|
+
.idea/**/mongoSettings.xml
|
|
49
|
+
|
|
50
|
+
# File-based project format
|
|
51
|
+
*.iws
|
|
52
|
+
|
|
53
|
+
# IntelliJ
|
|
54
|
+
out/
|
|
55
|
+
|
|
56
|
+
# mpeltonen/sbt-idea plugin
|
|
57
|
+
.idea_modules/
|
|
58
|
+
|
|
59
|
+
# JIRA plugin
|
|
60
|
+
atlassian-ide-plugin.xml
|
|
61
|
+
|
|
62
|
+
# Cursive Clojure plugin
|
|
63
|
+
.idea/replstate.xml
|
|
64
|
+
|
|
65
|
+
# SonarLint plugin
|
|
66
|
+
.idea/sonarlint/
|
|
67
|
+
|
|
68
|
+
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
69
|
+
com_crashlytics_export_strings.xml
|
|
70
|
+
crashlytics.properties
|
|
71
|
+
crashlytics-build.properties
|
|
72
|
+
fabric.properties
|
|
73
|
+
|
|
74
|
+
# Editor-based Rest Client
|
|
75
|
+
.idea/httpRequests
|
|
76
|
+
|
|
77
|
+
# Android studio 3.1+ serialized cache file
|
|
78
|
+
.idea/caches/build_file_checksums.ser
|
|
79
|
+
|
|
80
|
+
### Python template
|
|
81
|
+
# Byte-compiled / optimized / DLL files
|
|
82
|
+
__pycache__/
|
|
83
|
+
*.py[cod]
|
|
84
|
+
*$py.class
|
|
85
|
+
|
|
86
|
+
# C extensions
|
|
87
|
+
*.so
|
|
88
|
+
|
|
89
|
+
# Distribution / packaging
|
|
90
|
+
.Python
|
|
91
|
+
build/
|
|
92
|
+
develop-eggs/
|
|
93
|
+
dist/
|
|
94
|
+
downloads/
|
|
95
|
+
eggs/
|
|
96
|
+
.eggs/
|
|
97
|
+
lib/
|
|
98
|
+
lib64/
|
|
99
|
+
parts/
|
|
100
|
+
sdist/
|
|
101
|
+
var/
|
|
102
|
+
wheels/
|
|
103
|
+
share/python-wheels/
|
|
104
|
+
*.egg-info/
|
|
105
|
+
.installed.cfg
|
|
106
|
+
*.egg
|
|
107
|
+
MANIFEST
|
|
108
|
+
|
|
109
|
+
# PyInstaller
|
|
110
|
+
# Usually these files are written by a python script from a template
|
|
111
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
112
|
+
*.manifest
|
|
113
|
+
*.spec
|
|
114
|
+
|
|
115
|
+
# Installer logs
|
|
116
|
+
pip-log.txt
|
|
117
|
+
pip-delete-this-directory.txt
|
|
118
|
+
|
|
119
|
+
# Unit test / coverage reports
|
|
120
|
+
htmlcov/
|
|
121
|
+
.tox/
|
|
122
|
+
.nox/
|
|
123
|
+
.coverage
|
|
124
|
+
.coverage.*
|
|
125
|
+
.cache
|
|
126
|
+
nosetests.xml
|
|
127
|
+
coverage.xml
|
|
128
|
+
*.cover
|
|
129
|
+
*.py,cover
|
|
130
|
+
.hypothesis/
|
|
131
|
+
.pytest_cache/
|
|
132
|
+
cover/
|
|
133
|
+
|
|
134
|
+
# Translations
|
|
135
|
+
*.mo
|
|
136
|
+
*.pot
|
|
137
|
+
|
|
138
|
+
# Django stuff:
|
|
139
|
+
*.log
|
|
140
|
+
local_settings.py
|
|
141
|
+
db.sqlite3
|
|
142
|
+
db.sqlite3-journal
|
|
143
|
+
|
|
144
|
+
# Flask stuff:
|
|
145
|
+
instance/
|
|
146
|
+
.webassets-cache
|
|
147
|
+
|
|
148
|
+
# Scrapy stuff:
|
|
149
|
+
.scrapy
|
|
150
|
+
|
|
151
|
+
# Sphinx documentation
|
|
152
|
+
docs/_build/
|
|
153
|
+
|
|
154
|
+
# PyBuilder
|
|
155
|
+
.pybuilder/
|
|
156
|
+
target/
|
|
157
|
+
|
|
158
|
+
# Jupyter Notebook
|
|
159
|
+
.ipynb_checkpoints
|
|
160
|
+
|
|
161
|
+
# IPython
|
|
162
|
+
profile_default/
|
|
163
|
+
ipython_config.py
|
|
164
|
+
|
|
165
|
+
# pyenv
|
|
166
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
167
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
168
|
+
# .python-version
|
|
169
|
+
|
|
170
|
+
# pipenv
|
|
171
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
172
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
173
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
174
|
+
# install all needed dependencies.
|
|
175
|
+
#Pipfile.lock
|
|
176
|
+
|
|
177
|
+
# poetry
|
|
178
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
179
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
180
|
+
# commonly ignored for libraries.
|
|
181
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
182
|
+
#poetry.lock
|
|
183
|
+
|
|
184
|
+
# pdm
|
|
185
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
186
|
+
#pdm.lock
|
|
187
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
188
|
+
# in version control.
|
|
189
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
190
|
+
.pdm.toml
|
|
191
|
+
|
|
192
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
193
|
+
__pypackages__/
|
|
194
|
+
|
|
195
|
+
# Celery stuff
|
|
196
|
+
celerybeat-schedule
|
|
197
|
+
celerybeat.pid
|
|
198
|
+
|
|
199
|
+
# SageMath parsed files
|
|
200
|
+
*.sage.py
|
|
201
|
+
|
|
202
|
+
# Environments
|
|
203
|
+
.env
|
|
204
|
+
.venv
|
|
205
|
+
env/
|
|
206
|
+
venv/
|
|
207
|
+
ENV/
|
|
208
|
+
env.bak/
|
|
209
|
+
venv.bak/
|
|
210
|
+
|
|
211
|
+
# Spyder project settings
|
|
212
|
+
.spyderproject
|
|
213
|
+
.spyproject
|
|
214
|
+
|
|
215
|
+
# Rope project settings
|
|
216
|
+
.ropeproject
|
|
217
|
+
|
|
218
|
+
# mkdocs documentation
|
|
219
|
+
/site
|
|
220
|
+
|
|
221
|
+
# mypy
|
|
222
|
+
.mypy_cache/
|
|
223
|
+
.dmypy.json
|
|
224
|
+
dmypy.json
|
|
225
|
+
|
|
226
|
+
# Pyre type checker
|
|
227
|
+
.pyre/
|
|
228
|
+
|
|
229
|
+
# pytype static type analyzer
|
|
230
|
+
.pytype/
|
|
231
|
+
|
|
232
|
+
# Cython debug symbols
|
|
233
|
+
cython_debug/
|
|
234
|
+
|
|
235
|
+
# PyCharm
|
|
236
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
237
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
238
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
239
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
240
|
+
#.idea/
|
|
241
|
+
|
|
242
|
+
.ruff_cache
|
|
243
|
+
bandit-report.json
|
|
244
|
+
|
|
245
|
+
.gitattributes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: archipy-vault
|
|
3
|
+
Version: 5.11.0
|
|
4
|
+
Summary: ArchiPy vault adapter.
|
|
5
|
+
Author-email: Hossein Nejati <hosseinnejati14@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: <4,>=3.14
|
|
8
|
+
Requires-Dist: archipy==5.11.0
|
|
9
|
+
Requires-Dist: cachetools>=7.2.1
|
|
10
|
+
Requires-Dist: hvac[parser]>=2.4.0
|
|
11
|
+
Requires-Dist: requests>=2.34.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# archipy-vault
|
|
15
|
+
|
|
16
|
+
vault adapter for [ArchiPy](https://syntaxarc.github.io/ArchiPy/). Install via the core extra:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add "archipy[vault]"
|
|
20
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HashiCorp Vault adapter package."""
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""HashiCorp Vault adapter implementation using hvac."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any, NoReturn, override
|
|
8
|
+
|
|
9
|
+
import hvac.exceptions
|
|
10
|
+
from requests.exceptions import (
|
|
11
|
+
ConnectionError as RequestsConnectionError,
|
|
12
|
+
Timeout as RequestsTimeout,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from archipy.adapters.vault.ports import VaultPort
|
|
16
|
+
from archipy.configs.base_config import BaseConfig
|
|
17
|
+
from archipy.configs.config_template import VaultConfig
|
|
18
|
+
from archipy.configs.vault_settings_source import create_vault_client
|
|
19
|
+
from archipy.helpers.decorators import ttl_cache
|
|
20
|
+
from archipy.models.dtos.vault_lease_dto import VaultLeaseDTO
|
|
21
|
+
from archipy.models.errors import (
|
|
22
|
+
BaseError,
|
|
23
|
+
ConfigurationError,
|
|
24
|
+
InvalidArgumentError,
|
|
25
|
+
NetworkError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
PermissionDeniedError,
|
|
28
|
+
UnavailableError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _lease_from_response(
|
|
38
|
+
response: dict[str, Any],
|
|
39
|
+
*,
|
|
40
|
+
default_lease_id: str = "",
|
|
41
|
+
include_credential_data: bool = True,
|
|
42
|
+
) -> VaultLeaseDTO:
|
|
43
|
+
"""Build a ``VaultLeaseDTO`` from an hvac lease response.
|
|
44
|
+
|
|
45
|
+
Prefer top-level ``lease_id`` / ``lease_duration`` (hvac sys renew / database
|
|
46
|
+
generate_credentials). Fall back to nested ``data`` when needed.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
response: Parsed JSON response from hvac.
|
|
50
|
+
default_lease_id: Fallback lease id when the response omits one.
|
|
51
|
+
include_credential_data: When True, copy ``response["data"]`` into the DTO.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Normalized lease DTO.
|
|
55
|
+
"""
|
|
56
|
+
lease_source = response if "lease_id" in response else (response.get("data") or response)
|
|
57
|
+
if not isinstance(lease_source, dict):
|
|
58
|
+
lease_source = {}
|
|
59
|
+
credential_data = dict(response.get("data") or {}) if include_credential_data else {}
|
|
60
|
+
return VaultLeaseDTO(
|
|
61
|
+
lease_id=str(lease_source.get("lease_id", default_lease_id)),
|
|
62
|
+
lease_duration=int(lease_source.get("lease_duration", 0)),
|
|
63
|
+
renewable=bool(lease_source.get("renewable", False)),
|
|
64
|
+
data=credential_data,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VaultExceptionHandlerMixin:
|
|
69
|
+
"""Map hvac/network exceptions to ArchiPy domain errors."""
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _handle_vault_exception(cls, exception: Exception, operation: str) -> NoReturn:
|
|
73
|
+
"""Convert Vault client failures into domain errors.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
exception: Original exception from hvac or requests.
|
|
77
|
+
operation: Name of the failing operation.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
NotFoundError: When the path or secret does not exist.
|
|
81
|
+
PermissionDeniedError: When authz fails.
|
|
82
|
+
InvalidArgumentError: When Vault rejects the request parameters.
|
|
83
|
+
UnavailableError: When Vault is down or rate-limited.
|
|
84
|
+
NetworkError: When the network call fails.
|
|
85
|
+
ConfigurationError: For other Vault errors.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(exception, hvac.exceptions.InvalidPath):
|
|
88
|
+
raise NotFoundError(resource_type="vault_secret") from exception
|
|
89
|
+
if isinstance(exception, hvac.exceptions.Forbidden | hvac.exceptions.Unauthorized):
|
|
90
|
+
raise PermissionDeniedError(
|
|
91
|
+
additional_data={"details": f"Permission denied for Vault operation: {operation}"},
|
|
92
|
+
) from exception
|
|
93
|
+
if isinstance(
|
|
94
|
+
exception,
|
|
95
|
+
hvac.exceptions.InvalidRequest | hvac.exceptions.ParamValidationError,
|
|
96
|
+
):
|
|
97
|
+
raise InvalidArgumentError(argument_name=operation) from exception
|
|
98
|
+
if isinstance(exception, hvac.exceptions.VaultDown | hvac.exceptions.RateLimitExceeded):
|
|
99
|
+
raise UnavailableError(resource_type="Vault", additional_data={"operation": operation}) from exception
|
|
100
|
+
if isinstance(exception, RequestsConnectionError | RequestsTimeout):
|
|
101
|
+
raise NetworkError(service="Vault") from exception
|
|
102
|
+
if isinstance(exception, hvac.exceptions.VaultError):
|
|
103
|
+
raise ConfigurationError(operation=operation, reason=str(exception)) from exception
|
|
104
|
+
raise ConfigurationError(operation=operation, reason=str(exception)) from exception
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VaultAdapter(VaultPort, VaultExceptionHandlerMixin):
|
|
108
|
+
"""Concrete Vault adapter wrapping ``hvac.Client``."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, vault_configs: VaultConfig | None = None) -> None:
|
|
111
|
+
"""Initialize the Vault adapter.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
vault_configs: Optional Vault configuration. If None, uses
|
|
115
|
+
``BaseConfig.global_config().VAULT``.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ConfigurationError: If authentication or connection fails.
|
|
119
|
+
InvalidArgumentError: If required configuration is missing.
|
|
120
|
+
"""
|
|
121
|
+
if vault_configs is not None:
|
|
122
|
+
self.configs = vault_configs
|
|
123
|
+
else:
|
|
124
|
+
global_config = BaseConfig.global_config()
|
|
125
|
+
vault_config = getattr(global_config, "VAULT", None)
|
|
126
|
+
if not isinstance(vault_config, VaultConfig):
|
|
127
|
+
raise InvalidArgumentError(argument_name="VAULT")
|
|
128
|
+
self.configs = vault_config
|
|
129
|
+
|
|
130
|
+
if not self.configs.ADDR:
|
|
131
|
+
raise InvalidArgumentError(argument_name="ADDR")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
self._client = create_vault_client(self.configs)
|
|
135
|
+
except ConfigurationError:
|
|
136
|
+
raise
|
|
137
|
+
except (
|
|
138
|
+
hvac.exceptions.VaultError,
|
|
139
|
+
RequestsConnectionError,
|
|
140
|
+
RequestsTimeout,
|
|
141
|
+
OSError,
|
|
142
|
+
ValueError,
|
|
143
|
+
) as e:
|
|
144
|
+
raise ConfigurationError(operation="vault_connect", reason=str(e)) from e
|
|
145
|
+
|
|
146
|
+
self.token_renew_count = 0
|
|
147
|
+
|
|
148
|
+
# Per-instance TTL cache so SECRET_CACHE_TTL on this config is honored.
|
|
149
|
+
# Class-level decoration cannot see instance config and resolves TTL too early.
|
|
150
|
+
ttl = max(self.configs.SECRET_CACHE_TTL, 0)
|
|
151
|
+
self._read_secret_cached: Any | None = None
|
|
152
|
+
if ttl > 0:
|
|
153
|
+
cached = ttl_cache(ttl_seconds=ttl, maxsize=100)(VaultAdapter._read_secret_uncached)
|
|
154
|
+
self._read_secret_cached = cached.__get__(self, VaultAdapter)
|
|
155
|
+
|
|
156
|
+
def _mount(self, mount_point: str | None) -> str:
|
|
157
|
+
"""Resolve the KV mount point."""
|
|
158
|
+
return mount_point if mount_point is not None else self.configs.MOUNT_POINT
|
|
159
|
+
|
|
160
|
+
def _run[T](self, operation: str, fn: Callable[[], T]) -> T:
|
|
161
|
+
"""Execute a Vault call, mapping client failures to domain errors.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
operation: Operation name for error context.
|
|
165
|
+
fn: Zero-arg callable performing the Vault client work.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Whatever ``fn`` returns.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
BaseError: Domain errors raised by ``fn`` are re-raised unchanged.
|
|
172
|
+
ConfigurationError: For unmapped Vault/client failures.
|
|
173
|
+
"""
|
|
174
|
+
self._maybe_renew_token()
|
|
175
|
+
try:
|
|
176
|
+
return fn()
|
|
177
|
+
except BaseError:
|
|
178
|
+
raise
|
|
179
|
+
except (
|
|
180
|
+
hvac.exceptions.VaultError,
|
|
181
|
+
RequestsConnectionError,
|
|
182
|
+
RequestsTimeout,
|
|
183
|
+
OSError,
|
|
184
|
+
ValueError,
|
|
185
|
+
) as e:
|
|
186
|
+
self._handle_vault_exception(e, operation)
|
|
187
|
+
|
|
188
|
+
def _maybe_renew_token(self) -> None:
|
|
189
|
+
"""Renew the client token when AUTO_RENEW_TOKEN is enabled and TTL is low."""
|
|
190
|
+
if not self.configs.AUTO_RENEW_TOKEN:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
lookup = self._client.auth.token.lookup_self()
|
|
194
|
+
ttl = int(lookup.get("data", {}).get("ttl", 0))
|
|
195
|
+
if ttl <= self.configs.RENEW_THRESHOLD_SECONDS:
|
|
196
|
+
self._client.auth.token.renew_self()
|
|
197
|
+
self.token_renew_count += 1
|
|
198
|
+
logger.debug("Renewed Vault token (previous TTL=%s)", ttl)
|
|
199
|
+
except (
|
|
200
|
+
hvac.exceptions.VaultError,
|
|
201
|
+
RequestsConnectionError,
|
|
202
|
+
RequestsTimeout,
|
|
203
|
+
OSError,
|
|
204
|
+
ValueError,
|
|
205
|
+
) as e:
|
|
206
|
+
self._handle_vault_exception(e, "renew_token")
|
|
207
|
+
|
|
208
|
+
def clear_secret_cache(self) -> None:
|
|
209
|
+
"""Clear all cached ``read_secret`` results."""
|
|
210
|
+
cached = self._read_secret_cached
|
|
211
|
+
if cached is not None and hasattr(cached, "clear_cache"):
|
|
212
|
+
cached.clear_cache()
|
|
213
|
+
|
|
214
|
+
def _read_secret_uncached(self, path: str, mount_point: str | None = None) -> dict[str, Any]:
|
|
215
|
+
"""Fetch a KV v2 secret without using the instance cache."""
|
|
216
|
+
mount = self._mount(mount_point)
|
|
217
|
+
|
|
218
|
+
def _read() -> dict[str, Any]:
|
|
219
|
+
response = self._client.secrets.kv.v2.read_secret_version(
|
|
220
|
+
path=path,
|
|
221
|
+
mount_point=mount,
|
|
222
|
+
raise_on_deleted_version=True,
|
|
223
|
+
)
|
|
224
|
+
data = response.get("data", {}).get("data") or {}
|
|
225
|
+
if not isinstance(data, dict):
|
|
226
|
+
raise ConfigurationError(
|
|
227
|
+
operation="read_secret",
|
|
228
|
+
reason=f"Vault secret at '{path}' did not return a dict payload",
|
|
229
|
+
)
|
|
230
|
+
return {str(k): v for k, v in data.items()}
|
|
231
|
+
|
|
232
|
+
return self._run("read_secret", _read)
|
|
233
|
+
|
|
234
|
+
@override
|
|
235
|
+
def read_secret(self, path: str, mount_point: str | None = None) -> dict[str, Any]:
|
|
236
|
+
"""Read a KV v2 secret.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
path: Secret path within the mount.
|
|
240
|
+
mount_point: Optional KV v2 mount override.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Secret key/value payload.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
InvalidArgumentError: If path is empty.
|
|
247
|
+
NotFoundError: If the secret does not exist.
|
|
248
|
+
PermissionDeniedError: If access is denied.
|
|
249
|
+
ConfigurationError: For other Vault errors.
|
|
250
|
+
"""
|
|
251
|
+
if not path:
|
|
252
|
+
raise InvalidArgumentError(argument_name="path")
|
|
253
|
+
if self._read_secret_cached is not None:
|
|
254
|
+
return self._read_secret_cached(path, mount_point)
|
|
255
|
+
return self._read_secret_uncached(path, mount_point)
|
|
256
|
+
|
|
257
|
+
@override
|
|
258
|
+
def write_secret(
|
|
259
|
+
self,
|
|
260
|
+
path: str,
|
|
261
|
+
secret: dict[str, Any],
|
|
262
|
+
mount_point: str | None = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Write a KV v2 secret.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
path: Secret path within the mount.
|
|
268
|
+
secret: Key/value payload to store.
|
|
269
|
+
mount_point: Optional KV v2 mount override.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
InvalidArgumentError: If path is empty or secret is empty.
|
|
273
|
+
PermissionDeniedError: If access is denied.
|
|
274
|
+
ConfigurationError: For other Vault errors.
|
|
275
|
+
"""
|
|
276
|
+
if not path:
|
|
277
|
+
raise InvalidArgumentError(argument_name="path")
|
|
278
|
+
if not secret:
|
|
279
|
+
raise InvalidArgumentError(argument_name="secret")
|
|
280
|
+
mount = self._mount(mount_point)
|
|
281
|
+
|
|
282
|
+
def _write() -> None:
|
|
283
|
+
self._client.secrets.kv.v2.create_or_update_secret(
|
|
284
|
+
path=path,
|
|
285
|
+
secret=secret,
|
|
286
|
+
mount_point=mount,
|
|
287
|
+
)
|
|
288
|
+
self.clear_secret_cache()
|
|
289
|
+
|
|
290
|
+
self._run("write_secret", _write)
|
|
291
|
+
|
|
292
|
+
@override
|
|
293
|
+
def delete_secret(self, path: str, mount_point: str | None = None) -> None:
|
|
294
|
+
"""Delete a KV v2 secret including metadata and all versions.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
path: Secret path within the mount.
|
|
298
|
+
mount_point: Optional KV v2 mount override.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
InvalidArgumentError: If path is empty.
|
|
302
|
+
NotFoundError: If the secret does not exist.
|
|
303
|
+
PermissionDeniedError: If access is denied.
|
|
304
|
+
ConfigurationError: For other Vault errors.
|
|
305
|
+
"""
|
|
306
|
+
if not path:
|
|
307
|
+
raise InvalidArgumentError(argument_name="path")
|
|
308
|
+
mount = self._mount(mount_point)
|
|
309
|
+
|
|
310
|
+
def _delete() -> None:
|
|
311
|
+
self._client.secrets.kv.v2.delete_metadata_and_all_versions(
|
|
312
|
+
path=path,
|
|
313
|
+
mount_point=mount,
|
|
314
|
+
)
|
|
315
|
+
self.clear_secret_cache()
|
|
316
|
+
|
|
317
|
+
self._run("delete_secret", _delete)
|
|
318
|
+
|
|
319
|
+
@override
|
|
320
|
+
def list_secrets(self, path: str, mount_point: str | None = None) -> list[str]:
|
|
321
|
+
"""List secret keys under a path.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
path: Path prefix to list.
|
|
325
|
+
mount_point: Optional KV v2 mount override.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of key names.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
NotFoundError: If the path does not exist.
|
|
332
|
+
PermissionDeniedError: If access is denied.
|
|
333
|
+
ConfigurationError: For other Vault errors.
|
|
334
|
+
"""
|
|
335
|
+
mount = self._mount(mount_point)
|
|
336
|
+
|
|
337
|
+
def _list() -> list[str]:
|
|
338
|
+
response = self._client.secrets.kv.v2.list_secrets(path=path, mount_point=mount)
|
|
339
|
+
keys = response.get("data", {}).get("keys") or []
|
|
340
|
+
return [str(key) for key in keys]
|
|
341
|
+
|
|
342
|
+
return self._run("list_secrets", _list)
|
|
343
|
+
|
|
344
|
+
@override
|
|
345
|
+
def get_dynamic_credentials(
|
|
346
|
+
self,
|
|
347
|
+
mount_point: str,
|
|
348
|
+
role: str,
|
|
349
|
+
*,
|
|
350
|
+
parameters: dict[str, Any] | None = None,
|
|
351
|
+
) -> VaultLeaseDTO:
|
|
352
|
+
"""Generate dynamic credentials for a secrets-engine role.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
mount_point: Secrets engine mount (e.g. ``database`` or ``ssh``).
|
|
356
|
+
role: Role name that generates credentials.
|
|
357
|
+
parameters: Optional engine-specific parameters. When provided (including
|
|
358
|
+
an empty dict), issues a write to ``{mount}/creds/{role}`` (needed for
|
|
359
|
+
engines like SSH OTP). When ``None``, uses the typed database API
|
|
360
|
+
``secrets.database.generate_credentials``, falling back to a generic
|
|
361
|
+
read for non-database engines.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Lease metadata and credential payload.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
InvalidArgumentError: If mount_point or role is empty.
|
|
368
|
+
PermissionDeniedError: If access is denied.
|
|
369
|
+
ConfigurationError: For other Vault errors.
|
|
370
|
+
"""
|
|
371
|
+
if not mount_point:
|
|
372
|
+
raise InvalidArgumentError(argument_name="mount_point")
|
|
373
|
+
if not role:
|
|
374
|
+
raise InvalidArgumentError(argument_name="role")
|
|
375
|
+
path = f"{mount_point}/creds/{role}"
|
|
376
|
+
|
|
377
|
+
def _get_creds() -> VaultLeaseDTO:
|
|
378
|
+
if parameters is not None:
|
|
379
|
+
response = self._client.write(path, **parameters)
|
|
380
|
+
else:
|
|
381
|
+
try:
|
|
382
|
+
response = self._client.secrets.database.generate_credentials(
|
|
383
|
+
name=role,
|
|
384
|
+
mount_point=mount_point,
|
|
385
|
+
)
|
|
386
|
+
except hvac.exceptions.InvalidPath, hvac.exceptions.UnexpectedError:
|
|
387
|
+
response = self._client.read(path)
|
|
388
|
+
if response is None:
|
|
389
|
+
raise NotFoundError(resource_type="vault_role")
|
|
390
|
+
return _lease_from_response(response, include_credential_data=True)
|
|
391
|
+
|
|
392
|
+
return self._run("get_dynamic_credentials", _get_creds)
|
|
393
|
+
|
|
394
|
+
@override
|
|
395
|
+
def renew_lease(self, lease_id: str, increment: int | None = None) -> VaultLeaseDTO:
|
|
396
|
+
"""Renew a dynamic secret lease.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
lease_id: Lease identifier to renew.
|
|
400
|
+
increment: Optional requested TTL extension in seconds.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Updated lease metadata.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
InvalidArgumentError: If lease_id is empty.
|
|
407
|
+
ConfigurationError: For Vault errors.
|
|
408
|
+
"""
|
|
409
|
+
if not lease_id:
|
|
410
|
+
raise InvalidArgumentError(argument_name="lease_id")
|
|
411
|
+
|
|
412
|
+
def _renew() -> VaultLeaseDTO:
|
|
413
|
+
kwargs: dict[str, Any] = {"lease_id": lease_id}
|
|
414
|
+
if increment is not None:
|
|
415
|
+
kwargs["increment"] = increment
|
|
416
|
+
response = self._client.sys.renew_lease(**kwargs)
|
|
417
|
+
return _lease_from_response(
|
|
418
|
+
response,
|
|
419
|
+
default_lease_id=lease_id,
|
|
420
|
+
include_credential_data=False,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return self._run("renew_lease", _renew)
|
|
424
|
+
|
|
425
|
+
@override
|
|
426
|
+
def revoke_lease(self, lease_id: str) -> None:
|
|
427
|
+
"""Revoke a dynamic secret lease.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
lease_id: Lease identifier to revoke.
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
InvalidArgumentError: If lease_id is empty.
|
|
434
|
+
ConfigurationError: For Vault errors.
|
|
435
|
+
"""
|
|
436
|
+
if not lease_id:
|
|
437
|
+
raise InvalidArgumentError(argument_name="lease_id")
|
|
438
|
+
self._run("revoke_lease", lambda: self._client.sys.revoke_lease(lease_id=lease_id))
|
|
439
|
+
|
|
440
|
+
@override
|
|
441
|
+
def encrypt(self, key_name: str, plaintext: str, mount_point: str = "transit") -> str:
|
|
442
|
+
"""Encrypt plaintext using the transit secrets engine.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
key_name: Transit key name.
|
|
446
|
+
plaintext: UTF-8 plaintext to encrypt.
|
|
447
|
+
mount_point: Transit mount point.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Vault ciphertext string.
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
InvalidArgumentError: If key_name or plaintext is empty.
|
|
454
|
+
ConfigurationError: For Vault errors.
|
|
455
|
+
"""
|
|
456
|
+
if not key_name:
|
|
457
|
+
raise InvalidArgumentError(argument_name="key_name")
|
|
458
|
+
if plaintext == "":
|
|
459
|
+
raise InvalidArgumentError(argument_name="plaintext")
|
|
460
|
+
encoded = base64.b64encode(plaintext.encode("utf-8")).decode("ascii")
|
|
461
|
+
|
|
462
|
+
def _encrypt() -> str:
|
|
463
|
+
response = self._client.secrets.transit.encrypt_data(
|
|
464
|
+
name=key_name,
|
|
465
|
+
plaintext=encoded,
|
|
466
|
+
mount_point=mount_point,
|
|
467
|
+
)
|
|
468
|
+
return str(response["data"]["ciphertext"])
|
|
469
|
+
|
|
470
|
+
return self._run("encrypt", _encrypt)
|
|
471
|
+
|
|
472
|
+
@override
|
|
473
|
+
def decrypt(self, key_name: str, ciphertext: str, mount_point: str = "transit") -> str:
|
|
474
|
+
"""Decrypt ciphertext using the transit secrets engine.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
key_name: Transit key name.
|
|
478
|
+
ciphertext: Vault ciphertext string.
|
|
479
|
+
mount_point: Transit mount point.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Decoded UTF-8 plaintext.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
InvalidArgumentError: If key_name or ciphertext is empty.
|
|
486
|
+
ConfigurationError: For Vault errors.
|
|
487
|
+
"""
|
|
488
|
+
if not key_name:
|
|
489
|
+
raise InvalidArgumentError(argument_name="key_name")
|
|
490
|
+
if not ciphertext:
|
|
491
|
+
raise InvalidArgumentError(argument_name="ciphertext")
|
|
492
|
+
|
|
493
|
+
def _decrypt() -> str:
|
|
494
|
+
response = self._client.secrets.transit.decrypt_data(
|
|
495
|
+
name=key_name,
|
|
496
|
+
ciphertext=ciphertext,
|
|
497
|
+
mount_point=mount_point,
|
|
498
|
+
)
|
|
499
|
+
encoded = str(response["data"]["plaintext"])
|
|
500
|
+
return base64.b64decode(encoded.encode("ascii")).decode("utf-8")
|
|
501
|
+
|
|
502
|
+
return self._run("decrypt", _decrypt)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Port definitions for HashiCorp Vault operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from archipy.models.dtos.vault_lease_dto import VaultLeaseDTO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VaultPort(ABC):
|
|
13
|
+
"""Interface for HashiCorp Vault operations.
|
|
14
|
+
|
|
15
|
+
Defines the contract for Vault adapters covering KV v2 secrets, dynamic
|
|
16
|
+
leases, and transit encryption.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def read_secret(self, path: str, mount_point: str | None = None) -> dict[str, Any]:
|
|
21
|
+
"""Read a KV v2 secret.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
path: Secret path within the mount.
|
|
25
|
+
mount_point: Optional KV v2 mount override; defaults to config mount.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Secret key/value payload.
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def write_secret(
|
|
34
|
+
self,
|
|
35
|
+
path: str,
|
|
36
|
+
secret: dict[str, Any],
|
|
37
|
+
mount_point: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Write (create or update) a KV v2 secret.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
path: Secret path within the mount.
|
|
43
|
+
secret: Key/value payload to store.
|
|
44
|
+
mount_point: Optional KV v2 mount override; defaults to config mount.
|
|
45
|
+
"""
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def delete_secret(self, path: str, mount_point: str | None = None) -> None:
|
|
50
|
+
"""Delete a KV v2 secret (all versions and metadata).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: Secret path within the mount.
|
|
54
|
+
mount_point: Optional KV v2 mount override; defaults to config mount.
|
|
55
|
+
"""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def list_secrets(self, path: str, mount_point: str | None = None) -> list[str]:
|
|
60
|
+
"""List secret keys under a path.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Path prefix to list.
|
|
64
|
+
mount_point: Optional KV v2 mount override; defaults to config mount.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of key names (directories end with ``/``).
|
|
68
|
+
"""
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def get_dynamic_credentials(
|
|
73
|
+
self,
|
|
74
|
+
mount_point: str,
|
|
75
|
+
role: str,
|
|
76
|
+
*,
|
|
77
|
+
parameters: dict[str, Any] | None = None,
|
|
78
|
+
) -> VaultLeaseDTO:
|
|
79
|
+
"""Generate dynamic credentials for a secrets-engine role.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
mount_point: Secrets engine mount (e.g. ``database`` or ``ssh``).
|
|
83
|
+
role: Role name that generates credentials.
|
|
84
|
+
parameters: Optional engine-specific parameters (e.g. ``{"ip": "127.0.0.1"}``
|
|
85
|
+
for SSH OTP). When not ``None``, issues a write to ``{mount}/creds/{role}``.
|
|
86
|
+
When ``None``, uses the database secrets engine generate-credentials API
|
|
87
|
+
(with a generic read fallback for other engines).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Lease metadata and credential payload.
|
|
91
|
+
"""
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def renew_lease(self, lease_id: str, increment: int | None = None) -> VaultLeaseDTO:
|
|
96
|
+
"""Renew a dynamic secret lease.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
lease_id: Lease identifier to renew.
|
|
100
|
+
increment: Optional requested TTL extension in seconds.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Updated lease metadata.
|
|
104
|
+
"""
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def revoke_lease(self, lease_id: str) -> None:
|
|
109
|
+
"""Revoke a dynamic secret lease.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
lease_id: Lease identifier to revoke.
|
|
113
|
+
"""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def encrypt(self, key_name: str, plaintext: str, mount_point: str = "transit") -> str:
|
|
118
|
+
"""Encrypt plaintext using the transit secrets engine.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
key_name: Transit key name.
|
|
122
|
+
plaintext: UTF-8 plaintext to encrypt.
|
|
123
|
+
mount_point: Transit mount point.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Vault ciphertext string (e.g. ``vault:v1:...``).
|
|
127
|
+
"""
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def decrypt(self, key_name: str, ciphertext: str, mount_point: str = "transit") -> str:
|
|
132
|
+
"""Decrypt ciphertext using the transit secrets engine.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
key_name: Transit key name.
|
|
136
|
+
ciphertext: Vault ciphertext string.
|
|
137
|
+
mount_point: Transit mount point.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Decoded UTF-8 plaintext.
|
|
141
|
+
"""
|
|
142
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "archipy-vault"
|
|
3
|
+
description = "ArchiPy vault adapter."
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Hossein Nejati", email = "hosseinnejati14@gmail.com" },
|
|
6
|
+
]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
version = "5.11.0" # This version is set on publish flow!
|
|
9
|
+
requires-python = ">=3.14,<4"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"archipy==5.11.0", # Pinned to core version on publish flow!
|
|
12
|
+
"hvac[parser]>=2.4.0",
|
|
13
|
+
"requests>=2.34.2",
|
|
14
|
+
"cachetools>=7.2.1",
|
|
15
|
+
]
|
|
16
|
+
license = "Apache-2.0"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["archipy"]
|
|
24
|
+
|
|
25
|
+
[tool.uv.sources]
|
|
26
|
+
archipy = { workspace = true }
|