credential-bridge 0.1.0__py3-none-any.whl

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,45 @@
1
+ # src/credential_bridge/__init__.py
2
+ from ._version import __version__
3
+ from .backends import BaseSecretBackend, EnvFileBackend, KeyringBackend, VaultBackend
4
+ from .exceptions import (
5
+ BackendError,
6
+ BackendNotRegisteredError,
7
+ ConfigurationError,
8
+ CredentialBridgeError,
9
+ EnvFileError,
10
+ EnvFileKeyExistsError,
11
+ EnvFileNotFoundError,
12
+ KeyringError,
13
+ VaultAuthError,
14
+ VaultConnectionError,
15
+ VaultError,
16
+ VaultSecretNotFoundError,
17
+ )
18
+ from .manager import SecretsManager
19
+
20
+ # Backwards-compatibility aliases
21
+ VaultManager = VaultBackend
22
+ KeyringManager = KeyringBackend
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ "SecretsManager",
27
+ "BaseSecretBackend",
28
+ "VaultBackend",
29
+ "KeyringBackend",
30
+ "EnvFileBackend",
31
+ "CredentialBridgeError",
32
+ "BackendError",
33
+ "VaultError",
34
+ "VaultAuthError",
35
+ "VaultConnectionError",
36
+ "VaultSecretNotFoundError",
37
+ "KeyringError",
38
+ "EnvFileError",
39
+ "EnvFileKeyExistsError",
40
+ "EnvFileNotFoundError",
41
+ "BackendNotRegisteredError",
42
+ "ConfigurationError",
43
+ "VaultManager",
44
+ "KeyringManager",
45
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli.main import main
2
+
3
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,7 @@
1
+ # src/credential_bridge/backends/__init__.py
2
+ from .base import BaseSecretBackend
3
+ from .env_file import EnvFileBackend
4
+ from .keyring import KeyringBackend
5
+ from .vault import VaultBackend
6
+
7
+ __all__ = ["BaseSecretBackend", "EnvFileBackend", "KeyringBackend", "VaultBackend"]
@@ -0,0 +1,53 @@
1
+ """Abstract base class for all secret backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Dict, List
5
+
6
+
7
+ class BaseSecretBackend(ABC):
8
+ """Contract that every secrets backend must fulfil."""
9
+
10
+ backend_name: str = ""
11
+
12
+ def __init_subclass__(cls, **kwargs: Any) -> None:
13
+ super().__init_subclass__(**kwargs)
14
+ # __abstractmethods__ is not yet populated when __init_subclass__ runs,
15
+ # so we collect abstract method names from the MRO manually.
16
+ abstract_names = {
17
+ name
18
+ for base in cls.__mro__
19
+ for name, val in vars(base).items()
20
+ if getattr(val, "__isabstractmethod__", False)
21
+ }
22
+ # Only enforce backend_name on concrete (fully-implemented) subclasses
23
+ overridden = {
24
+ name
25
+ for name in abstract_names
26
+ if name in cls.__dict__ and not getattr(cls.__dict__[name], "__isabstractmethod__", False)
27
+ }
28
+ if abstract_names and overridden >= abstract_names:
29
+ # All abstract methods are implemented — this is a concrete subclass
30
+ if not cls.backend_name:
31
+ raise TypeError(
32
+ f"{cls.__name__} must define a non-empty 'backend_name' class attribute."
33
+ )
34
+
35
+ @abstractmethod
36
+ def add_secret(self, name: str, secret: Dict[str, Any]) -> None:
37
+ """Store a new secret. Creates a new version if the secret already exists (Vault); raises if key already exists (EnvFile)."""
38
+
39
+ @abstractmethod
40
+ def get_secret(self, name: str) -> Dict[str, Any]:
41
+ """Retrieve a secret by name. Raises if not found."""
42
+
43
+ @abstractmethod
44
+ def update_secret(self, name: str, secret: Dict[str, Any]) -> None:
45
+ """Update an existing secret. Raises if not found."""
46
+
47
+ @abstractmethod
48
+ def delete_secret(self, name: str) -> None:
49
+ """Delete a secret. Raises if not found."""
50
+
51
+ @abstractmethod
52
+ def list_secrets(self, path: str = "") -> List[str]:
53
+ """List secret names, optionally under a path prefix."""
@@ -0,0 +1,120 @@
1
+ # src/credential_bridge/backends/env_file.py
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ from dotenv import dotenv_values
7
+
8
+ from ..exceptions import EnvFileError, EnvFileKeyExistsError, EnvFileNotFoundError
9
+ from .base import BaseSecretBackend
10
+
11
+
12
+ def _quote_value(value: str) -> str:
13
+ """Quote a .env value if it contains spaces or special characters."""
14
+ # Already properly quoted — both open and close with same quote char
15
+ if len(value) >= 2:
16
+ if (value[0] == '"' and value[-1] == '"') or \
17
+ (value[0] == "'" and value[-1] == "'"):
18
+ return value
19
+ # Needs quoting if contains spaces or special chars
20
+ if any(c in value for c in (' ', '\t', '#', '"', "'", '\\', '$', '`')):
21
+ escaped = value.replace('\\', '\\\\').replace('"', '\\"')
22
+ return f'"{escaped}"'
23
+ return value
24
+
25
+
26
+ class EnvFileBackend(BaseSecretBackend):
27
+ """.env file secrets backend with full CRUD and atomic writes."""
28
+
29
+ backend_name = "env"
30
+
31
+ def __init__(
32
+ self,
33
+ path: Union[str, Path] = ".env",
34
+ load_into_environ: bool = False,
35
+ encoding: str = "utf-8",
36
+ ) -> None:
37
+ self.path = Path(path).resolve()
38
+ self.load_into_environ = load_into_environ
39
+ self.encoding = encoding
40
+
41
+ def _read_lines(self) -> List[str]:
42
+ if not self.path.exists():
43
+ return []
44
+ return self.path.read_text(encoding=self.encoding).splitlines(keepends=True)
45
+
46
+ def _write_lines(self, lines: List[str]) -> None:
47
+ tmp = self.path.parent / (self.path.name + ".tmp")
48
+ try:
49
+ tmp.write_text("".join(lines), encoding=self.encoding)
50
+ os.replace(str(tmp), str(self.path))
51
+ except Exception:
52
+ tmp.unlink(missing_ok=True)
53
+ raise
54
+
55
+ def _current_keys(self) -> Dict[str, str]:
56
+ return dict(dotenv_values(self.path)) if self.path.exists() else {}
57
+
58
+ def _sync_environ(self, keys: Dict[str, str]) -> None:
59
+ for k, v in keys.items():
60
+ os.environ[k] = str(v)
61
+
62
+ def add_secret(self, name: str, secret: Dict[str, Any]) -> None:
63
+ existing = self._current_keys()
64
+ conflicts = [k for k in secret if k in existing]
65
+ if conflicts:
66
+ raise EnvFileKeyExistsError(
67
+ f"Key(s) already exist in {self.path}: {conflicts}. "
68
+ "Use update_secret() to change them."
69
+ )
70
+ lines = self._read_lines()
71
+ lines.append(f"\n# {name}\n")
72
+ for k, v in secret.items():
73
+ lines.append(f"{k}={_quote_value(str(v))}\n")
74
+ self._write_lines(lines)
75
+ if self.load_into_environ:
76
+ self._sync_environ({k: str(v) for k, v in secret.items()})
77
+
78
+ def get_secret(self, name: str) -> Dict[str, Any]:
79
+ keys = self._current_keys()
80
+ if name not in keys:
81
+ raise EnvFileNotFoundError(f"Key '{name}' not found in {self.path}.")
82
+ return {name: keys[name]}
83
+
84
+ def update_secret(self, name: str, secret: Dict[str, Any]) -> None:
85
+ existing = self._current_keys()
86
+ missing = [k for k in secret if k not in existing]
87
+ if missing:
88
+ raise EnvFileError(
89
+ f"Key(s) {missing} not found in {self.path}. Use add_secret() first."
90
+ )
91
+ lines = self._read_lines()
92
+ updated: Dict[str, str] = {}
93
+ new_lines = []
94
+ for line in lines:
95
+ if "=" in line and not line.strip().startswith("#"):
96
+ key = line.split("=", 1)[0].strip()
97
+ if key in secret:
98
+ new_lines.append(f"{key}={_quote_value(str(secret[key]))}\n")
99
+ updated[key] = str(secret[key])
100
+ continue
101
+ new_lines.append(line)
102
+ self._write_lines(new_lines)
103
+ if self.load_into_environ:
104
+ self._sync_environ(updated)
105
+
106
+ def delete_secret(self, name: str) -> None:
107
+ existing = self._current_keys()
108
+ if name not in existing:
109
+ raise EnvFileNotFoundError(f"Key '{name}' not found in {self.path}.")
110
+ lines = self._read_lines()
111
+ new_lines = [
112
+ line for line in lines
113
+ if not ("=" in line and not line.strip().startswith("#") and line.split("=", 1)[0].strip() == name)
114
+ ]
115
+ self._write_lines(new_lines)
116
+ if self.load_into_environ:
117
+ os.environ.pop(name, None)
118
+
119
+ def list_secrets(self, path: str = "") -> List[str]:
120
+ return list(self._current_keys().keys())
@@ -0,0 +1,95 @@
1
+ """System keyring backend for credential-bridge."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ import keyring
7
+ from keyring.errors import KeyringError as _KeyringLibError
8
+ from pylogshield import LogLevel, PyLogShield, get_logger
9
+
10
+ from ..exceptions import ConfigurationError, KeyringError
11
+ from .base import BaseSecretBackend
12
+
13
+
14
+ class KeyringBackend(BaseSecretBackend):
15
+ """System keyring backend. Stores dicts as JSON strings."""
16
+
17
+ backend_name = "keyring"
18
+
19
+ def __init__(
20
+ self,
21
+ service_name: str = "default_service",
22
+ log_level: Union[LogLevel, str] = LogLevel.WARNING,
23
+ logger: Optional[PyLogShield] = None,
24
+ mask: bool = True,
25
+ ) -> None:
26
+ self.service_name = service_name
27
+ self.mask = mask
28
+ if logger and not isinstance(logger, PyLogShield):
29
+ raise ConfigurationError("logger must be a PyLogShield instance.")
30
+ self.logger = logger or get_logger(name="credential_bridge", log_level=log_level, force=True)
31
+
32
+ def __repr__(self) -> str:
33
+ return f"KeyringBackend(service_name={self.service_name!r})"
34
+
35
+ def add_secret(self, name: str, secret: Dict[str, Any]) -> None:
36
+ try:
37
+ existing = keyring.get_password(self.service_name, name)
38
+ if existing is not None:
39
+ raise KeyringError(
40
+ f"Secret '{name}' already exists in keyring service '{self.service_name}'. "
41
+ "Use update_secret() to change it."
42
+ )
43
+ keyring.set_password(self.service_name, name, json.dumps(secret))
44
+ self.logger.info(f"Keyring secret added: {name}", mask=self.mask)
45
+ except KeyringError:
46
+ raise
47
+ except _KeyringLibError as e:
48
+ raise KeyringError(f"Failed to add '{name}': {e}") from e
49
+
50
+ def get_secret(self, name: str) -> Dict[str, Any]:
51
+ try:
52
+ value = keyring.get_password(self.service_name, name)
53
+ if value is None:
54
+ raise KeyringError(
55
+ f"Secret '{name}' not found in keyring service '{self.service_name}'."
56
+ )
57
+ return json.loads(value)
58
+ except KeyringError:
59
+ raise
60
+ except _KeyringLibError as e:
61
+ raise KeyringError(f"Failed to get '{name}': {e}") from e
62
+
63
+ def update_secret(self, name: str, secret: Dict[str, Any]) -> None:
64
+ try:
65
+ existing = keyring.get_password(self.service_name, name)
66
+ if existing is None:
67
+ raise KeyringError(
68
+ f"Secret '{name}' does not exist — use add_secret() first."
69
+ )
70
+ keyring.set_password(self.service_name, name, json.dumps(secret))
71
+ self.logger.info(f"Keyring secret updated: {name}", mask=self.mask)
72
+ except KeyringError:
73
+ raise
74
+ except _KeyringLibError as e:
75
+ raise KeyringError(f"Failed to update '{name}': {e}") from e
76
+
77
+ def delete_secret(self, name: str) -> None:
78
+ try:
79
+ existing = keyring.get_password(self.service_name, name)
80
+ if existing is None:
81
+ raise KeyringError(
82
+ f"Secret '{name}' not found in keyring service '{self.service_name}'."
83
+ )
84
+ keyring.delete_password(self.service_name, name)
85
+ self.logger.info(f"Keyring secret deleted: {name}")
86
+ except KeyringError:
87
+ raise
88
+ except _KeyringLibError as e:
89
+ raise KeyringError(f"Failed to delete '{name}': {e}") from e
90
+
91
+ def list_secrets(self, path: str = "") -> List[str]:
92
+ raise KeyringError(
93
+ "KeyringBackend.list_secrets() is not supported on this platform. "
94
+ "Windows Credential Manager and macOS Keychain do not expose enumeration APIs."
95
+ )
@@ -0,0 +1,315 @@
1
+ """HashiCorp Vault backend for credential-bridge."""
2
+
3
+ import os
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ import hvac
7
+ import requests
8
+ from pylogshield import LogLevel, PyLogShield, get_logger
9
+
10
+ from credential_bridge.backends.base import BaseSecretBackend
11
+ from credential_bridge.exceptions import (
12
+ ConfigurationError,
13
+ VaultAuthError,
14
+ VaultConnectionError,
15
+ VaultError,
16
+ VaultSecretNotFoundError,
17
+ )
18
+ from credential_bridge.utils import get_session, load_config, save_config
19
+
20
+
21
+ class VaultBackend(BaseSecretBackend):
22
+ """HashiCorp Vault KV-v2 secret backend."""
23
+
24
+ backend_name: str = "vault"
25
+
26
+ def __init__(
27
+ self,
28
+ vault_url: Optional[str] = None,
29
+ vault_token: Optional[str] = None,
30
+ vault_role_id: Optional[str] = None,
31
+ vault_secret_id: Optional[str] = None,
32
+ service_name: str = "default_service",
33
+ mount_point: str = "secret",
34
+ proxies: Optional[Dict[str, str]] = None,
35
+ cert: Optional[str] = None,
36
+ log_level: Union[LogLevel, str] = LogLevel.WARNING,
37
+ logger: Optional[PyLogShield] = None,
38
+ mask: bool = True,
39
+ persist: bool = False, # opt-in credential persistence to ~/.vault_config.json
40
+ ) -> None:
41
+ self.mask = mask
42
+ self.service_name = service_name
43
+ self.mount_point = mount_point
44
+ self.cert = cert # None means use system CA bundle; path string means custom cert
45
+ self.proxies = proxies
46
+
47
+ if logger and not isinstance(logger, PyLogShield):
48
+ raise ConfigurationError(
49
+ "logger must be a PyLogShield instance. "
50
+ "Use: from pylogshield import PyLogShield"
51
+ )
52
+ self.logger = logger or get_logger(name="credential_bridge", log_level=log_level, force=True)
53
+
54
+ self.session = get_session(cert, proxies)
55
+
56
+ config = load_config()
57
+
58
+ # --- Resolve vault address ---
59
+ if vault_url:
60
+ self.vault_addr = vault_url
61
+ else:
62
+ self.vault_addr = os.environ.get("VAULT_ADDR") or config.get("vault_addr")
63
+
64
+ if not self.vault_addr:
65
+ raise ConfigurationError(
66
+ "Vault address must be provided via the vault_url argument, "
67
+ "the VAULT_ADDR environment variable, or ~/.vault_config.json"
68
+ )
69
+
70
+ # --- Resolve credentials (args override config) ---
71
+ self.vault_token = vault_token or config.get("vault_token")
72
+ self.vault_role_id = vault_role_id or config.get("vault_role_id")
73
+ self.vault_secret_id = vault_secret_id or config.get("vault_secret_id")
74
+
75
+ # Token and AppRole are mutually exclusive
76
+ if self.vault_token and (self.vault_role_id or self.vault_secret_id):
77
+ raise ConfigurationError(
78
+ "Provide either a Vault token or AppRole credentials, not both."
79
+ )
80
+
81
+ # At least one auth method must be present
82
+ if not self.vault_token and not (self.vault_role_id and self.vault_secret_id):
83
+ raise ConfigurationError(
84
+ "No authentication method provided. Please provide either a token "
85
+ "or AppRole credentials."
86
+ )
87
+
88
+ # --- Persist credentials to config (opt-in) ---
89
+ if persist:
90
+ if vault_token:
91
+ config["vault_token"] = vault_token
92
+ config["vault_role_id"] = None
93
+ config["vault_secret_id"] = None
94
+ elif vault_role_id and vault_secret_id:
95
+ config["vault_role_id"] = vault_role_id
96
+ config["vault_secret_id"] = vault_secret_id
97
+ config["vault_token"] = None
98
+
99
+ if vault_url:
100
+ config["vault_addr"] = vault_url
101
+
102
+ save_config(config)
103
+
104
+ self.client = self._authenticate()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Authentication
108
+ # ------------------------------------------------------------------
109
+
110
+ def _authenticate(self) -> hvac.Client:
111
+ """Authenticate with Vault using a token or AppRole credentials."""
112
+ self.logger.info("Authenticating with Vault...")
113
+ try:
114
+ if self.vault_token:
115
+ client = hvac.Client(
116
+ url=self.vault_addr,
117
+ token=self.vault_token,
118
+ session=self.session,
119
+ verify=self.cert,
120
+ )
121
+ if not client.is_authenticated():
122
+ raise VaultAuthError(
123
+ "Failed to authenticate with Vault using token."
124
+ )
125
+ self.logger.info("Authenticated with Vault via token.")
126
+ return client
127
+
128
+ # AppRole
129
+ client = hvac.Client(
130
+ url=self.vault_addr,
131
+ session=self.session,
132
+ verify=self.cert,
133
+ )
134
+ auth_response = client.auth.approle.login(
135
+ role_id=self.vault_role_id,
136
+ secret_id=self.vault_secret_id,
137
+ )
138
+ if "auth" not in auth_response or "client_token" not in auth_response["auth"]:
139
+ raise VaultAuthError(
140
+ "Failed to authenticate with Vault using AppRole."
141
+ )
142
+ client.token = auth_response["auth"]["client_token"]
143
+ self.logger.info("Authenticated with Vault via AppRole.")
144
+ return client
145
+
146
+ except VaultAuthError:
147
+ raise
148
+ except VaultConnectionError:
149
+ raise
150
+ except hvac.exceptions.Forbidden as exc:
151
+ raise VaultAuthError(f"Forbidden: {exc}") from exc
152
+ except hvac.exceptions.InvalidRequest as exc:
153
+ raise VaultAuthError(f"Invalid request: {exc}") from exc
154
+ except (hvac.exceptions.VaultDown, requests.ConnectionError, requests.Timeout) as exc:
155
+ raise VaultConnectionError(f"Cannot connect to Vault at {self.vault_addr}: {exc}") from exc
156
+ except (ConnectionError, OSError) as exc:
157
+ raise VaultConnectionError(f"Cannot reach Vault at {self.vault_addr}: {exc}") from exc
158
+ except Exception as exc:
159
+ raise VaultError(f"Vault authentication error: {exc}") from exc
160
+
161
+ def _refresh_token_if_needed(self) -> None:
162
+ """Renew the Vault token if its TTL is below 5 minutes."""
163
+ try:
164
+ resp = self.client.auth.token.lookup_self()
165
+ if resp["data"]["ttl"] < 300:
166
+ self.client.auth.token.renew_self()
167
+ self.logger.info("Vault token refreshed.")
168
+ except hvac.exceptions.Forbidden as e:
169
+ raise VaultAuthError(f"Vault token is invalid or has expired: {e}") from e
170
+ except Exception as e:
171
+ self.logger.warning(f"Token refresh check failed (will retry on next operation): {e}")
172
+
173
+ # ------------------------------------------------------------------
174
+ # BaseSecretBackend interface
175
+ # ------------------------------------------------------------------
176
+
177
+ def add_secret(self, name: str, secret: Dict[str, Any]) -> None:
178
+ """Add or update a secret in Vault (creates a new KV-v2 version)."""
179
+ self._refresh_token_if_needed()
180
+ try:
181
+ self.client.secrets.kv.v2.create_or_update_secret(
182
+ path=name,
183
+ secret=secret,
184
+ mount_point=self.mount_point,
185
+ )
186
+ self.logger.info(f"Secret added: {name}")
187
+ except Exception as exc:
188
+ raise VaultError(f"Failed to add secret '{name}': {exc}") from exc
189
+
190
+ def get_secret(self, name: str) -> Dict[str, Any]:
191
+ """Retrieve a secret by *name*."""
192
+ self._refresh_token_if_needed()
193
+ try:
194
+ response = self.client.secrets.kv.v2.read_secret(
195
+ path=name,
196
+ mount_point=self.mount_point,
197
+ )
198
+ return response["data"]["data"]
199
+ except hvac.exceptions.InvalidPath as e:
200
+ raise VaultSecretNotFoundError(f"Secret path '{name}' does not exist: {e}") from e
201
+ except Exception as exc:
202
+ raise VaultError(f"Failed to get secret '{name}': {exc}") from exc
203
+
204
+ def update_secret(self, name: str, secret: Dict[str, Any]) -> None:
205
+ """Update an existing secret."""
206
+ self._refresh_token_if_needed()
207
+ try:
208
+ self.client.secrets.kv.v2.patch(
209
+ path=name,
210
+ secret=secret,
211
+ mount_point=self.mount_point,
212
+ )
213
+ self.logger.info(f"Secret updated: {name}")
214
+ except hvac.exceptions.InvalidPath as e:
215
+ raise VaultSecretNotFoundError(f"Secret path '{name}' does not exist: {e}") from e
216
+ except Exception as exc:
217
+ raise VaultError(f"Failed to update secret '{name}': {exc}") from exc
218
+
219
+ def delete_secret(self, name: str) -> None:
220
+ """Permanently delete a secret and all its versions."""
221
+ self._refresh_token_if_needed()
222
+ try:
223
+ self.client.secrets.kv.v2.delete_metadata_and_all_versions(
224
+ path=name,
225
+ mount_point=self.mount_point,
226
+ )
227
+ self.logger.info(f"Secret deleted: {name}")
228
+ except hvac.exceptions.InvalidPath as e:
229
+ raise VaultSecretNotFoundError(f"Secret path '{name}' does not exist: {e}") from e
230
+ except Exception as exc:
231
+ raise VaultError(f"Failed to delete secret '{name}': {exc}") from exc
232
+
233
+ def list_secrets(self, path: str = "") -> List[str]:
234
+ """List secret keys under *path*."""
235
+ self._refresh_token_if_needed()
236
+ try:
237
+ response = self.client.secrets.kv.v2.list_secrets(
238
+ path=path,
239
+ mount_point=self.mount_point,
240
+ )
241
+ return response["data"]["keys"]
242
+ except Exception as exc:
243
+ raise VaultError(f"Failed to list secrets at '{path}': {exc}") from exc
244
+
245
+ # ------------------------------------------------------------------
246
+ # Extra helpers
247
+ # ------------------------------------------------------------------
248
+
249
+ def get_config(self) -> Optional[Dict[str, Any]]:
250
+ """Return the KV engine configuration for the current mount point."""
251
+ self._refresh_token_if_needed()
252
+ try:
253
+ return self.client.secrets.kv.v2.read_configuration(
254
+ mount_point=self.mount_point
255
+ )
256
+ except Exception as exc:
257
+ raise VaultError(
258
+ f"Failed to read config for mount '{self.mount_point}': {exc}"
259
+ ) from exc
260
+
261
+ def read_secret_metadata(self, name: str) -> Optional[Dict[str, Any]]:
262
+ """Return metadata and version info for *name*."""
263
+ self._refresh_token_if_needed()
264
+ try:
265
+ return self.client.secrets.kv.v2.read_secret_metadata(
266
+ path=name,
267
+ mount_point=self.mount_point,
268
+ )
269
+ except Exception as exc:
270
+ raise VaultError(f"Failed to read metadata for '{name}': {exc}") from exc
271
+
272
+ def delete_secret_versions(self, name: str, versions: List[int]) -> None:
273
+ """Soft-delete specific versions of *name*."""
274
+ self._refresh_token_if_needed()
275
+ try:
276
+ self.client.secrets.kv.v2.delete_secret_versions(
277
+ path=name,
278
+ versions=versions,
279
+ mount_point=self.mount_point,
280
+ )
281
+ self.logger.info(f"Soft-deleted versions {versions} of '{name}'.")
282
+ except Exception as exc:
283
+ raise VaultError(
284
+ f"Failed to delete versions {versions} of '{name}': {exc}"
285
+ ) from exc
286
+
287
+ def undelete_secret_versions(self, name: str, versions: List[int]) -> None:
288
+ """Restore soft-deleted versions of *name*."""
289
+ self._refresh_token_if_needed()
290
+ try:
291
+ self.client.secrets.kv.v2.undelete_secret_versions(
292
+ path=name,
293
+ versions=versions,
294
+ mount_point=self.mount_point,
295
+ )
296
+ self.logger.info(f"Undeleted versions {versions} of '{name}'.")
297
+ except Exception as exc:
298
+ raise VaultError(
299
+ f"Failed to undelete versions {versions} of '{name}': {exc}"
300
+ ) from exc
301
+
302
+ def destroy_secret_versions(self, name: str, versions: List[int]) -> None:
303
+ """Permanently destroy specific versions of *name*."""
304
+ self._refresh_token_if_needed()
305
+ try:
306
+ self.client.secrets.kv.v2.destroy_secret_versions(
307
+ path=name,
308
+ versions=versions,
309
+ mount_point=self.mount_point,
310
+ )
311
+ self.logger.info(f"Destroyed versions {versions} of '{name}'.")
312
+ except Exception as exc:
313
+ raise VaultError(
314
+ f"Failed to destroy versions {versions} of '{name}': {exc}"
315
+ ) from exc
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()