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.
@@ -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,7 @@
1
+ # archipy-vault
2
+
3
+ vault adapter for [ArchiPy](https://syntaxarc.github.io/ArchiPy/). Install via the core extra:
4
+
5
+ ```bash
6
+ uv add "archipy[vault]"
7
+ ```
@@ -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 }