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.
- credential_bridge/__init__.py +45 -0
- credential_bridge/__main__.py +3 -0
- credential_bridge/_version.py +24 -0
- credential_bridge/backends/__init__.py +7 -0
- credential_bridge/backends/base.py +53 -0
- credential_bridge/backends/env_file.py +120 -0
- credential_bridge/backends/keyring.py +95 -0
- credential_bridge/backends/vault.py +315 -0
- credential_bridge/cli/__init__.py +0 -0
- credential_bridge/cli/__main__.py +3 -0
- credential_bridge/cli/_output.py +60 -0
- credential_bridge/cli/env_cli.py +137 -0
- credential_bridge/cli/keyring_cli.py +118 -0
- credential_bridge/cli/main.py +51 -0
- credential_bridge/cli/vault_cli.py +189 -0
- credential_bridge/exceptions.py +49 -0
- credential_bridge/manager.py +53 -0
- credential_bridge/prompt_wizard.py +556 -0
- credential_bridge/py.typed +0 -0
- credential_bridge/utils.py +95 -0
- credential_bridge/welcome_banner.txt +8 -0
- credential_bridge-0.1.0.dist-info/METADATA +316 -0
- credential_bridge-0.1.0.dist-info/RECORD +27 -0
- credential_bridge-0.1.0.dist-info/WHEEL +5 -0
- credential_bridge-0.1.0.dist-info/entry_points.txt +6 -0
- credential_bridge-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- credential_bridge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|