envdrift 4.2.1__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.
- envdrift/__init__.py +30 -0
- envdrift/_version.py +34 -0
- envdrift/api.py +192 -0
- envdrift/cli.py +42 -0
- envdrift/cli_commands/__init__.py +1 -0
- envdrift/cli_commands/diff.py +91 -0
- envdrift/cli_commands/encryption.py +630 -0
- envdrift/cli_commands/encryption_helpers.py +93 -0
- envdrift/cli_commands/hook.py +75 -0
- envdrift/cli_commands/init_cmd.py +117 -0
- envdrift/cli_commands/partial.py +222 -0
- envdrift/cli_commands/sync.py +1140 -0
- envdrift/cli_commands/validate.py +109 -0
- envdrift/cli_commands/vault.py +376 -0
- envdrift/cli_commands/version.py +15 -0
- envdrift/config.py +489 -0
- envdrift/constants.json +18 -0
- envdrift/core/__init__.py +30 -0
- envdrift/core/diff.py +233 -0
- envdrift/core/encryption.py +400 -0
- envdrift/core/parser.py +260 -0
- envdrift/core/partial_encryption.py +239 -0
- envdrift/core/schema.py +253 -0
- envdrift/core/validator.py +312 -0
- envdrift/encryption/__init__.py +117 -0
- envdrift/encryption/base.py +217 -0
- envdrift/encryption/dotenvx.py +236 -0
- envdrift/encryption/sops.py +458 -0
- envdrift/env_files.py +60 -0
- envdrift/integrations/__init__.py +21 -0
- envdrift/integrations/dotenvx.py +689 -0
- envdrift/integrations/precommit.py +266 -0
- envdrift/integrations/sops.py +85 -0
- envdrift/output/__init__.py +21 -0
- envdrift/output/rich.py +424 -0
- envdrift/py.typed +0 -0
- envdrift/sync/__init__.py +26 -0
- envdrift/sync/config.py +218 -0
- envdrift/sync/engine.py +383 -0
- envdrift/sync/operations.py +138 -0
- envdrift/sync/result.py +99 -0
- envdrift/vault/__init__.py +107 -0
- envdrift/vault/aws.py +282 -0
- envdrift/vault/azure.py +170 -0
- envdrift/vault/base.py +150 -0
- envdrift/vault/gcp.py +210 -0
- envdrift/vault/hashicorp.py +238 -0
- envdrift-4.2.1.dist-info/METADATA +160 -0
- envdrift-4.2.1.dist-info/RECORD +52 -0
- envdrift-4.2.1.dist-info/WHEEL +4 -0
- envdrift-4.2.1.dist-info/entry_points.txt +2 -0
- envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
envdrift/vault/base.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Abstract base class for vault clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VaultError(Exception):
|
|
11
|
+
"""Base exception for vault operations."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(VaultError):
|
|
17
|
+
"""Authentication to vault failed."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretNotFoundError(VaultError):
|
|
23
|
+
"""Secret not found in vault."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SecretValue:
|
|
30
|
+
"""Value retrieved from vault."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
value: str
|
|
34
|
+
version: str | None = None
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Produce a string representation of the SecretValue with the secret value masked.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
str: A string in the form "SecretValue(name=<name>, value=****)" where the actual secret value is redacted.
|
|
43
|
+
"""
|
|
44
|
+
return f"SecretValue(name={self.name}, value=****)"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VaultClient(ABC):
|
|
48
|
+
"""Abstract interface for vault backends.
|
|
49
|
+
|
|
50
|
+
Implementations must provide:
|
|
51
|
+
- get_secret: Retrieve a secret by name
|
|
52
|
+
- list_secrets: List available secret names
|
|
53
|
+
- is_authenticated: Check authentication status
|
|
54
|
+
- authenticate: Perform authentication
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def get_secret(self, name: str) -> SecretValue:
|
|
59
|
+
"""
|
|
60
|
+
Retrieve the secret identified by `name` from the vault.
|
|
61
|
+
|
|
62
|
+
Parameters:
|
|
63
|
+
name (str): Secret name or path within the vault.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
SecretValue: The secret object containing the secret's value and metadata.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
SecretNotFoundError: If the secret does not exist.
|
|
70
|
+
AuthenticationError: If the client is not authenticated.
|
|
71
|
+
VaultError: For other vault-related errors.
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
77
|
+
"""
|
|
78
|
+
List secret names available in the vault, optionally filtered by a prefix.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
prefix (str): Optional prefix to filter returned secret names.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
list[str]: Secret names that match the prefix (or all secret names if prefix is empty).
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
AuthenticationError: If the client is not authenticated.
|
|
88
|
+
VaultError: For other vault-related errors.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def is_authenticated(self) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Determine whether the client is currently authenticated.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if the client is authenticated, False otherwise.
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def authenticate(self) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Authenticate the client with the vault.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
AuthenticationError: If authentication fails.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def set_secret(self, name: str, value: str) -> SecretValue:
|
|
114
|
+
"""
|
|
115
|
+
Create or update a secret in the vault.
|
|
116
|
+
|
|
117
|
+
Parameters:
|
|
118
|
+
name (str): Secret name or path within the vault.
|
|
119
|
+
value (str): The secret value to store.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
SecretValue: The stored secret object containing name, value, version, and metadata.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
AuthenticationError: If the client is not authenticated or lacks write permissions.
|
|
126
|
+
VaultError: For other vault-related errors.
|
|
127
|
+
"""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
def get_secret_value(self, name: str) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Retrieve the value string of a secret identified by name.
|
|
133
|
+
|
|
134
|
+
Parameters:
|
|
135
|
+
name (str): The secret's name or path.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The secret's value string.
|
|
139
|
+
"""
|
|
140
|
+
return self.get_secret(name).value
|
|
141
|
+
|
|
142
|
+
def ensure_authenticated(self) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Authenticate the client if it is not already authenticated.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
AuthenticationError: If authentication fails.
|
|
148
|
+
"""
|
|
149
|
+
if not self.is_authenticated():
|
|
150
|
+
self.authenticate()
|
envdrift/vault/gcp.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""GCP Secret Manager client implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
|
|
7
|
+
from envdrift.vault.base import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
SecretNotFoundError,
|
|
10
|
+
SecretValue,
|
|
11
|
+
VaultClient,
|
|
12
|
+
VaultError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from google.api_core import exceptions as google_exceptions
|
|
17
|
+
from google.auth.exceptions import DefaultCredentialsError
|
|
18
|
+
from google.cloud import secretmanager
|
|
19
|
+
|
|
20
|
+
GCP_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
GCP_AVAILABLE = False
|
|
23
|
+
secretmanager = None
|
|
24
|
+
google_exceptions = None
|
|
25
|
+
DefaultCredentialsError = Exception
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GCPSecretManagerClient(VaultClient):
|
|
29
|
+
"""GCP Secret Manager implementation.
|
|
30
|
+
|
|
31
|
+
Uses Application Default Credentials which support:
|
|
32
|
+
- GOOGLE_APPLICATION_CREDENTIALS env var
|
|
33
|
+
- gcloud auth application-default login
|
|
34
|
+
- Workload Identity / service account bindings
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_id: str):
|
|
38
|
+
"""
|
|
39
|
+
Create a GCP Secret Manager client bound to the provided project ID.
|
|
40
|
+
|
|
41
|
+
Parameters:
|
|
42
|
+
project_id (str): GCP project ID (e.g., "my-gcp-project").
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ImportError: If the GCP SDK is not installed (install with `pip install envdrift[gcp]`).
|
|
46
|
+
"""
|
|
47
|
+
if not GCP_AVAILABLE:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"GCP Secret Manager support requires additional dependencies. "
|
|
50
|
+
"Install with: pip install envdrift[gcp]"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.project_id = project_id
|
|
54
|
+
self._client: secretmanager.SecretManagerServiceClient | None = None
|
|
55
|
+
|
|
56
|
+
def _project_path(self) -> str:
|
|
57
|
+
return f"projects/{self.project_id}"
|
|
58
|
+
|
|
59
|
+
def _secret_id(self, name: str) -> str:
|
|
60
|
+
if name.startswith("projects/"):
|
|
61
|
+
parts = name.split("/")
|
|
62
|
+
if "secrets" in parts:
|
|
63
|
+
idx = parts.index("secrets")
|
|
64
|
+
if idx + 1 < len(parts):
|
|
65
|
+
return parts[idx + 1]
|
|
66
|
+
return name
|
|
67
|
+
|
|
68
|
+
def _secret_path(self, name: str) -> str:
|
|
69
|
+
return f"{self._project_path()}/secrets/{self._secret_id(name)}"
|
|
70
|
+
|
|
71
|
+
def _version_path(self, name: str, version: str = "latest") -> str:
|
|
72
|
+
if name.startswith("projects/") and "/versions/" in name:
|
|
73
|
+
return name
|
|
74
|
+
if name.startswith("projects/") and "/secrets/" in name:
|
|
75
|
+
return f"{name}/versions/{version}"
|
|
76
|
+
return f"{self._secret_path(name)}/versions/{version}"
|
|
77
|
+
|
|
78
|
+
def authenticate(self) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Authenticate to GCP Secret Manager and initialize the client.
|
|
81
|
+
|
|
82
|
+
Raises AuthenticationError for credential issues and VaultError for API failures.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
self._client = secretmanager.SecretManagerServiceClient()
|
|
86
|
+
secrets_iter = self._client.list_secrets(
|
|
87
|
+
request={"parent": self._project_path(), "page_size": 1}
|
|
88
|
+
)
|
|
89
|
+
next(iter(secrets_iter), None)
|
|
90
|
+
except DefaultCredentialsError as e:
|
|
91
|
+
self._client = None
|
|
92
|
+
raise AuthenticationError(f"GCP authentication failed: {e}") from e
|
|
93
|
+
except (
|
|
94
|
+
google_exceptions.PermissionDenied,
|
|
95
|
+
google_exceptions.Unauthenticated,
|
|
96
|
+
) as e:
|
|
97
|
+
self._client = None
|
|
98
|
+
raise AuthenticationError(f"GCP authentication failed: {e}") from e
|
|
99
|
+
except google_exceptions.GoogleAPICallError as e:
|
|
100
|
+
self._client = None
|
|
101
|
+
raise VaultError(f"GCP Secret Manager error: {e}") from e
|
|
102
|
+
|
|
103
|
+
def is_authenticated(self) -> bool:
|
|
104
|
+
return self._client is not None
|
|
105
|
+
|
|
106
|
+
def get_secret(self, name: str) -> SecretValue:
|
|
107
|
+
"""
|
|
108
|
+
Retrieve a secret from GCP Secret Manager.
|
|
109
|
+
|
|
110
|
+
Parameters:
|
|
111
|
+
name (str): Secret name or full resource path.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
SecretValue: Contains the secret's name, value, version, and metadata.
|
|
115
|
+
"""
|
|
116
|
+
self.ensure_authenticated()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
version_path = self._version_path(name)
|
|
120
|
+
response = self._client.access_secret_version(request={"name": version_path})
|
|
121
|
+
payload = response.payload.data if response.payload else b""
|
|
122
|
+
try:
|
|
123
|
+
value = payload.decode("utf-8")
|
|
124
|
+
except UnicodeDecodeError:
|
|
125
|
+
import base64
|
|
126
|
+
|
|
127
|
+
value = base64.b64encode(payload).decode("ascii")
|
|
128
|
+
version = response.name.split("/")[-1] if response.name else None
|
|
129
|
+
return SecretValue(
|
|
130
|
+
name=self._secret_id(name),
|
|
131
|
+
value=value,
|
|
132
|
+
version=version,
|
|
133
|
+
metadata={"name": response.name},
|
|
134
|
+
)
|
|
135
|
+
except google_exceptions.NotFound as e:
|
|
136
|
+
raise SecretNotFoundError(f"Secret '{name}' not found") from e
|
|
137
|
+
except (
|
|
138
|
+
google_exceptions.PermissionDenied,
|
|
139
|
+
google_exceptions.Unauthenticated,
|
|
140
|
+
) as e:
|
|
141
|
+
raise AuthenticationError(f"Access denied to secret '{name}': {e}") from e
|
|
142
|
+
except google_exceptions.GoogleAPICallError as e:
|
|
143
|
+
raise VaultError(f"GCP Secret Manager error: {e}") from e
|
|
144
|
+
|
|
145
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
146
|
+
"""
|
|
147
|
+
List secret names in the project, optionally filtered by a prefix.
|
|
148
|
+
|
|
149
|
+
Parameters:
|
|
150
|
+
prefix (str): Optional prefix to filter secret names.
|
|
151
|
+
"""
|
|
152
|
+
self.ensure_authenticated()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
secrets = []
|
|
156
|
+
for secret in self._client.list_secrets(request={"parent": self._project_path()}):
|
|
157
|
+
secret_id = secret.name.split("/")[-1] if secret.name else ""
|
|
158
|
+
if secret_id and (not prefix or secret_id.startswith(prefix)):
|
|
159
|
+
secrets.append(secret_id)
|
|
160
|
+
return sorted(secrets)
|
|
161
|
+
except (
|
|
162
|
+
google_exceptions.PermissionDenied,
|
|
163
|
+
google_exceptions.Unauthenticated,
|
|
164
|
+
) as e:
|
|
165
|
+
raise AuthenticationError(f"Access denied to list secrets: {e}") from e
|
|
166
|
+
except google_exceptions.GoogleAPICallError as e:
|
|
167
|
+
raise VaultError(f"GCP Secret Manager error: {e}") from e
|
|
168
|
+
|
|
169
|
+
def set_secret(self, name: str, value: str) -> SecretValue:
|
|
170
|
+
"""
|
|
171
|
+
Create or update a secret in GCP Secret Manager.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
SecretValue containing the stored secret's name, value, version, and metadata.
|
|
175
|
+
"""
|
|
176
|
+
self.ensure_authenticated()
|
|
177
|
+
|
|
178
|
+
secret_id = self._secret_id(name)
|
|
179
|
+
secret_path = self._secret_path(name)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with contextlib.suppress(google_exceptions.AlreadyExists):
|
|
183
|
+
self._client.create_secret(
|
|
184
|
+
request={
|
|
185
|
+
"parent": self._project_path(),
|
|
186
|
+
"secret_id": secret_id,
|
|
187
|
+
"secret": {"replication": {"automatic": {}}},
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
version = self._client.add_secret_version(
|
|
192
|
+
request={
|
|
193
|
+
"parent": secret_path,
|
|
194
|
+
"payload": {"data": value.encode("utf-8")},
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
version_id = version.name.split("/")[-1] if version.name else None
|
|
198
|
+
return SecretValue(
|
|
199
|
+
name=secret_id,
|
|
200
|
+
value=value,
|
|
201
|
+
version=version_id,
|
|
202
|
+
metadata={"name": version.name},
|
|
203
|
+
)
|
|
204
|
+
except (
|
|
205
|
+
google_exceptions.PermissionDenied,
|
|
206
|
+
google_exceptions.Unauthenticated,
|
|
207
|
+
) as e:
|
|
208
|
+
raise AuthenticationError(f"Access denied to write secret '{name}': {e}") from e
|
|
209
|
+
except google_exceptions.GoogleAPICallError as e:
|
|
210
|
+
raise VaultError(f"GCP Secret Manager error: {e}") from e
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""HashiCorp Vault client implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from envdrift.vault.base import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
SecretNotFoundError,
|
|
10
|
+
SecretValue,
|
|
11
|
+
VaultClient,
|
|
12
|
+
VaultError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import hvac
|
|
17
|
+
from hvac.exceptions import Forbidden, InvalidPath, Unauthorized
|
|
18
|
+
|
|
19
|
+
HVAC_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HVAC_AVAILABLE = False
|
|
22
|
+
hvac = None
|
|
23
|
+
InvalidPath = Exception
|
|
24
|
+
Forbidden = Exception
|
|
25
|
+
Unauthorized = Exception
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HashiCorpVaultClient(VaultClient):
|
|
29
|
+
"""HashiCorp Vault implementation.
|
|
30
|
+
|
|
31
|
+
Supports KV v2 secrets engine (the default in modern Vault).
|
|
32
|
+
|
|
33
|
+
Authentication methods supported:
|
|
34
|
+
- Token (via token parameter or VAULT_TOKEN env var)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
url: str,
|
|
40
|
+
token: str | None = None,
|
|
41
|
+
mount_point: str = "secret",
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Create a HashiCorp Vault client configured to use the KV v2 secrets engine.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
url (str): Vault server URL (e.g., "https://vault.example.com:8200").
|
|
48
|
+
token (str | None): Authentication token; if omitted, the VAULT_TOKEN environment variable is used.
|
|
49
|
+
mount_point (str): KV secrets engine mount point (default "secret").
|
|
50
|
+
"""
|
|
51
|
+
if not HVAC_AVAILABLE:
|
|
52
|
+
raise ImportError("hvac not installed. Install with: pip install envdrift[hashicorp]")
|
|
53
|
+
|
|
54
|
+
self.url = url
|
|
55
|
+
self.token = token or os.environ.get("VAULT_TOKEN")
|
|
56
|
+
self.mount_point = mount_point
|
|
57
|
+
self._client: hvac.Client | None = None
|
|
58
|
+
|
|
59
|
+
def authenticate(self) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Authenticate the client against HashiCorp Vault using the configured token.
|
|
62
|
+
|
|
63
|
+
This initializes and verifies the underlying hvac client and stores it on the instance
|
|
64
|
+
when authentication succeeds.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
AuthenticationError: If no token was provided, the token is invalid, expired, or Vault
|
|
68
|
+
rejects authentication (including Unauthorized or Forbidden responses).
|
|
69
|
+
VaultError: For other connection or unexpected errors communicating with Vault.
|
|
70
|
+
"""
|
|
71
|
+
if not self.token:
|
|
72
|
+
raise AuthenticationError(
|
|
73
|
+
"No Vault token provided. Set VAULT_TOKEN or pass token parameter."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
self._client = hvac.Client(url=self.url, token=self.token)
|
|
78
|
+
|
|
79
|
+
if not self._client.is_authenticated():
|
|
80
|
+
raise AuthenticationError("Vault token is invalid or expired")
|
|
81
|
+
except (Unauthorized, Forbidden) as e:
|
|
82
|
+
raise AuthenticationError(f"Vault authentication failed: {e}") from e
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise VaultError(f"Vault connection error: {e}") from e
|
|
85
|
+
|
|
86
|
+
def is_authenticated(self) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Return whether the stored hvac client is currently authenticated.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
bool: `True` if an internal hvac client exists and reports it is authenticated, `False` otherwise.
|
|
92
|
+
"""
|
|
93
|
+
if self._client is None:
|
|
94
|
+
return False
|
|
95
|
+
try:
|
|
96
|
+
return self._client.is_authenticated()
|
|
97
|
+
except Exception:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def get_secret(self, name: str) -> SecretValue:
|
|
101
|
+
"""
|
|
102
|
+
Retrieve a secret from Vault at the given path relative to the client's mount point.
|
|
103
|
+
|
|
104
|
+
If the stored secret data contains only a single key named "value", that value is returned; otherwise the entire secret data dict is JSON-encoded and returned as the value. The returned SecretValue includes the secret's version and metadata (created_time, deletion_time, destroyed, custom_metadata).
|
|
105
|
+
|
|
106
|
+
Parameters:
|
|
107
|
+
name: Secret path relative to the configured mount point.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A SecretValue containing the secret's value, version, and metadata.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
SecretNotFoundError: If the secret path does not exist.
|
|
114
|
+
AuthenticationError: If access to the secret is denied or the client is unauthenticated.
|
|
115
|
+
VaultError: For other Vault-related errors.
|
|
116
|
+
"""
|
|
117
|
+
self.ensure_authenticated()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
response = self._client.secrets.kv.v2.read_secret_version(
|
|
121
|
+
path=name,
|
|
122
|
+
mount_point=self.mount_point,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
data = response.get("data", {})
|
|
126
|
+
secret_data = data.get("data", {})
|
|
127
|
+
metadata = data.get("metadata", {})
|
|
128
|
+
|
|
129
|
+
# If there's a single "value" key, return that
|
|
130
|
+
# Otherwise return the JSON string of all data
|
|
131
|
+
if "value" in secret_data and len(secret_data) == 1:
|
|
132
|
+
value = secret_data["value"]
|
|
133
|
+
else:
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
value = json.dumps(secret_data)
|
|
137
|
+
|
|
138
|
+
return SecretValue(
|
|
139
|
+
name=name,
|
|
140
|
+
value=value,
|
|
141
|
+
version=str(metadata.get("version", "")),
|
|
142
|
+
metadata={
|
|
143
|
+
"created_time": metadata.get("created_time"),
|
|
144
|
+
"deletion_time": metadata.get("deletion_time"),
|
|
145
|
+
"destroyed": metadata.get("destroyed", False),
|
|
146
|
+
"custom_metadata": metadata.get("custom_metadata", {}),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
except InvalidPath as e:
|
|
150
|
+
raise SecretNotFoundError(f"Secret '{name}' not found in Vault") from e
|
|
151
|
+
except (Unauthorized, Forbidden) as e:
|
|
152
|
+
raise AuthenticationError(f"Access denied to secret '{name}': {e}") from e
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise VaultError(f"Vault error: {e}") from e
|
|
155
|
+
|
|
156
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
157
|
+
"""List secret paths in HashiCorp Vault.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
prefix: Path prefix to list under
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of secret paths
|
|
164
|
+
"""
|
|
165
|
+
self.ensure_authenticated()
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
response = self._client.secrets.kv.v2.list_secrets(
|
|
169
|
+
path=prefix,
|
|
170
|
+
mount_point=self.mount_point,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
keys = response.get("data", {}).get("keys", [])
|
|
174
|
+
return sorted(keys)
|
|
175
|
+
except InvalidPath:
|
|
176
|
+
# Path doesn't exist, return empty list
|
|
177
|
+
return []
|
|
178
|
+
except (Unauthorized, Forbidden) as e:
|
|
179
|
+
raise AuthenticationError(f"Access denied to list secrets: {e}") from e
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise VaultError(f"Vault error: {e}") from e
|
|
182
|
+
|
|
183
|
+
def create_or_update_secret(self, name: str, data: dict) -> SecretValue:
|
|
184
|
+
"""
|
|
185
|
+
Create or update a secret at the given Vault path.
|
|
186
|
+
|
|
187
|
+
Parameters:
|
|
188
|
+
name (str): Secret path in the KV v2 engine.
|
|
189
|
+
data (dict): Dictionary of key-value pairs to store for the secret.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
SecretValue: The stored secret representation containing the secret `name`, a string `value` (JSON-encoded when multiple keys), the secret `version`, and metadata including `created_time`.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
AuthenticationError: If the client is not authorized to write the secret.
|
|
196
|
+
VaultError: For other Vault-related errors.
|
|
197
|
+
"""
|
|
198
|
+
self.ensure_authenticated()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
response = self._client.secrets.kv.v2.create_or_update_secret(
|
|
202
|
+
path=name,
|
|
203
|
+
secret=data,
|
|
204
|
+
mount_point=self.mount_point,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
metadata = response.get("data", {})
|
|
208
|
+
|
|
209
|
+
# Use same value extraction logic as get_secret for consistency
|
|
210
|
+
import json
|
|
211
|
+
|
|
212
|
+
value = data["value"] if ("value" in data and len(data) == 1) else json.dumps(data)
|
|
213
|
+
|
|
214
|
+
return SecretValue(
|
|
215
|
+
name=name,
|
|
216
|
+
value=value,
|
|
217
|
+
version=str(metadata.get("version", "")),
|
|
218
|
+
metadata={
|
|
219
|
+
"created_time": metadata.get("created_time"),
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
except (Unauthorized, Forbidden) as e:
|
|
223
|
+
raise AuthenticationError(f"Access denied to write secret: {e}") from e
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise VaultError(f"Vault error: {e}") from e
|
|
226
|
+
|
|
227
|
+
def set_secret(self, name: str, value: str) -> SecretValue:
|
|
228
|
+
"""
|
|
229
|
+
Set a string secret at the given path.
|
|
230
|
+
|
|
231
|
+
Parameters:
|
|
232
|
+
name (str): Secret path in Vault.
|
|
233
|
+
value (str): Secret string to store.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
SecretValue: The created or updated secret, including its stored value and metadata.
|
|
237
|
+
"""
|
|
238
|
+
return self.create_or_update_secret(name, {"value": value})
|