winwin-vault 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.
- winwin_vault/__init__.py +20 -0
- winwin_vault/auth.py +162 -0
- winwin_vault/cache.py +95 -0
- winwin_vault/client.py +290 -0
- winwin_vault/exceptions.py +25 -0
- winwin_vault/model.py +22 -0
- winwin_vault/py.typed +0 -0
- winwin_vault-0.1.0.dist-info/METADATA +92 -0
- winwin_vault-0.1.0.dist-info/RECORD +10 -0
- winwin_vault-0.1.0.dist-info/WHEEL +4 -0
winwin_vault/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""winwin_vault - A read-only Vault client library."""
|
|
2
|
+
|
|
3
|
+
from .client import VaultClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
VaultAuthenticationError,
|
|
6
|
+
VaultClientError,
|
|
7
|
+
VaultConnectionError,
|
|
8
|
+
VaultSecretNotFoundError,
|
|
9
|
+
)
|
|
10
|
+
from .model import AlicloudCredential, DatabaseCredential
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"VaultClient",
|
|
14
|
+
"VaultClientError",
|
|
15
|
+
"VaultAuthenticationError",
|
|
16
|
+
"VaultConnectionError",
|
|
17
|
+
"VaultSecretNotFoundError",
|
|
18
|
+
"DatabaseCredential",
|
|
19
|
+
"AlicloudCredential",
|
|
20
|
+
]
|
winwin_vault/auth.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Authentication handlers for Vault client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import hvac
|
|
9
|
+
|
|
10
|
+
from .exceptions import VaultAuthenticationError, VaultClientError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthHandler(Protocol):
|
|
14
|
+
"""Protocol for authentication handlers."""
|
|
15
|
+
|
|
16
|
+
def authenticate(self) -> hvac.Client:
|
|
17
|
+
"""Authenticate and return an hvac Client instance."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
K8S_TOKEN_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
|
22
|
+
VAULT_TOKEN_PATH = Path.home() / ".vault-token"
|
|
23
|
+
# Default timeout for hvac.Client (connect, read) in seconds
|
|
24
|
+
DEFAULT_TIMEOUT = (5, 30)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_https(vault_addr: str) -> bool:
|
|
28
|
+
"""Check if vault_addr uses HTTPS scheme."""
|
|
29
|
+
return urlparse(vault_addr).scheme == "https"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_local_agent(vault_addr: str) -> bool:
|
|
33
|
+
"""Check if vault_addr points to a local Vault Agent.
|
|
34
|
+
|
|
35
|
+
Vault Agent runs on the same host and handles auth automatically,
|
|
36
|
+
so we skip HTTPS enforcement and token requirements.
|
|
37
|
+
"""
|
|
38
|
+
parsed = urlparse(vault_addr)
|
|
39
|
+
return parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _create_client(
|
|
43
|
+
vault_addr: str,
|
|
44
|
+
timeout: tuple[int, int] = DEFAULT_TIMEOUT,
|
|
45
|
+
require_https: bool = True,
|
|
46
|
+
) -> hvac.Client:
|
|
47
|
+
"""Create an hvac Client configured with the given address.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
vault_addr: Vault server URL.
|
|
51
|
+
timeout: Tuple of (connect_timeout, read_timeout) in seconds.
|
|
52
|
+
require_https: If False, allow HTTP (for Vault Agent on localhost).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Configured hvac Client.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
VaultClientError: if VAULT_ADDR is invalid or uses wrong scheme.
|
|
59
|
+
"""
|
|
60
|
+
if not vault_addr:
|
|
61
|
+
raise VaultClientError("VAULT_ADDR must be set")
|
|
62
|
+
if require_https and not _is_https(vault_addr):
|
|
63
|
+
raise VaultClientError(f"VAULT_ADDR must use HTTPS scheme, got: {vault_addr}")
|
|
64
|
+
return hvac.Client(url=vault_addr, timeout=timeout) # type: ignore[reportArgumentType]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class EnvTokenAuth:
|
|
68
|
+
"""Authenticate using VAULT_TOKEN or ~/.vault-token.
|
|
69
|
+
|
|
70
|
+
If VAULT_TOKEN is not set, hvac reads ~/.vault-token automatically
|
|
71
|
+
after a successful `vault login`.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, vault_addr: str) -> None:
|
|
75
|
+
self._vault_addr = vault_addr
|
|
76
|
+
self._token = os.environ.get("VAULT_TOKEN", "")
|
|
77
|
+
|
|
78
|
+
def authenticate(self) -> hvac.Client:
|
|
79
|
+
client = _create_client(self._vault_addr)
|
|
80
|
+
# Only set token explicitly if VAULT_TOKEN is set;
|
|
81
|
+
# otherwise hvac will auto-discover ~/.vault-token
|
|
82
|
+
if self._token:
|
|
83
|
+
client.token = self._token
|
|
84
|
+
return client
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class VaultAgentAuth:
|
|
88
|
+
"""Authenticate via local Vault Agent sidecar.
|
|
89
|
+
|
|
90
|
+
Vault Agent runs on localhost and handles token lifecycle automatically.
|
|
91
|
+
The client connects over plain HTTP — no HTTPS required, no token needed.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, vault_addr: str) -> None:
|
|
95
|
+
self._vault_addr = vault_addr
|
|
96
|
+
|
|
97
|
+
def authenticate(self) -> hvac.Client:
|
|
98
|
+
# Vault Agent does not require HTTPS — it is the TLS terminator
|
|
99
|
+
client = _create_client(self._vault_addr, require_https=False)
|
|
100
|
+
# No token needed — Vault Agent injects it automatically
|
|
101
|
+
return client
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class KubernetesAuth:
|
|
105
|
+
"""Authenticate using Kubernetes ServiceAccount token."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, vault_addr: str, role: str = "default") -> None:
|
|
108
|
+
self._vault_addr = vault_addr
|
|
109
|
+
self._token_path = K8S_TOKEN_PATH
|
|
110
|
+
self._role = role
|
|
111
|
+
|
|
112
|
+
def authenticate(self) -> hvac.Client:
|
|
113
|
+
token_file = Path(self._token_path)
|
|
114
|
+
if not token_file.exists():
|
|
115
|
+
raise VaultAuthenticationError(
|
|
116
|
+
f"Kubernetes ServiceAccount token not found at {self._token_path}"
|
|
117
|
+
)
|
|
118
|
+
token = token_file.read_text().strip()
|
|
119
|
+
if not token:
|
|
120
|
+
raise VaultAuthenticationError(
|
|
121
|
+
f"Kubernetes ServiceAccount token at {self._token_path} is empty"
|
|
122
|
+
)
|
|
123
|
+
client = _create_client(self._vault_addr)
|
|
124
|
+
client.auth.kubernetes.login(role=self._role, jwt=token)
|
|
125
|
+
return client
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_auth_method() -> AuthHandler:
|
|
129
|
+
"""Detect the appropriate authentication method from environment.
|
|
130
|
+
|
|
131
|
+
Priority:
|
|
132
|
+
1. Vault Agent — if VAULT_ADDR uses http://localhost/127.0.0.1
|
|
133
|
+
2. VAULT_TOKEN (explicit) or ~/.vault-token (auto-discovered by hvac)
|
|
134
|
+
3. Kubernetes ServiceAccount token
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
VaultAgentAuth, EnvTokenAuth, or KubernetesAuth.
|
|
138
|
+
Raises:
|
|
139
|
+
VaultAuthenticationError: If no valid auth method is available.
|
|
140
|
+
"""
|
|
141
|
+
vault_addr = os.environ.get("VAULT_ADDR", "")
|
|
142
|
+
token = os.environ.get("VAULT_TOKEN", "")
|
|
143
|
+
|
|
144
|
+
# Vault Agent takes precedence — it is a local sidecar requiring no token
|
|
145
|
+
if vault_addr and _is_local_agent(vault_addr):
|
|
146
|
+
return VaultAgentAuth(vault_addr)
|
|
147
|
+
|
|
148
|
+
# EnvTokenAuth handles both explicit VAULT_TOKEN and ~/.vault-token
|
|
149
|
+
# (hvac auto-discovers ~/.vault-token when token is not set)
|
|
150
|
+
if token or VAULT_TOKEN_PATH.exists():
|
|
151
|
+
return EnvTokenAuth(vault_addr)
|
|
152
|
+
|
|
153
|
+
# Check for Kubernetes token
|
|
154
|
+
if K8S_TOKEN_PATH.exists():
|
|
155
|
+
role = os.environ.get("VAULT_K8S_ROLE", "default")
|
|
156
|
+
return KubernetesAuth(vault_addr, role=role)
|
|
157
|
+
|
|
158
|
+
raise VaultAuthenticationError(
|
|
159
|
+
"No authentication method available. "
|
|
160
|
+
"Set VAULT_TOKEN, run `vault login`, use Vault Agent, "
|
|
161
|
+
"or run inside Kubernetes with ServiceAccount token."
|
|
162
|
+
)
|
winwin_vault/cache.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""TTL-based cache for dynamic secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TTLCache:
|
|
13
|
+
"""In-memory TTL cache for dynamic secrets.
|
|
14
|
+
|
|
15
|
+
Thread-safe using per-path locks to prevent thundering herd on cache miss.
|
|
16
|
+
Fetches fresh data before TTL expires to avoid credential expiry gaps.
|
|
17
|
+
|
|
18
|
+
The fetch function is provided by the caller and handles all Vault interaction
|
|
19
|
+
(including retry and token renewal) — TTLCache only handles caching.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, max_size: int = 1000, refresh_threshold: float = 0.8) -> None:
|
|
23
|
+
"""Initialize TTLCache.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
max_size: Maximum number of entries before LRU eviction.
|
|
27
|
+
refresh_threshold: Fraction of TTL at which to refresh (0.8 = 80%).
|
|
28
|
+
"""
|
|
29
|
+
self._refresh_threshold = refresh_threshold
|
|
30
|
+
self._max_size = max_size
|
|
31
|
+
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
# Per-path locks to prevent thundering herd: only one fetcher per path
|
|
34
|
+
self._path_locks: dict[str, threading.Lock] = {}
|
|
35
|
+
self._path_locks_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def _is_expiring_soon(self, cached: dict[str, Any]) -> bool:
|
|
38
|
+
"""Check if a cached entry is within the refresh window."""
|
|
39
|
+
now = time.time()
|
|
40
|
+
expires_at = cached.get("expires_at", 0)
|
|
41
|
+
lease_duration = cached.get("lease_duration", 0)
|
|
42
|
+
return (expires_at - now) <= (lease_duration * (1 - self._refresh_threshold))
|
|
43
|
+
|
|
44
|
+
def _get_path_lock(self, path: str) -> threading.Lock:
|
|
45
|
+
"""Get or create a lock for a specific path."""
|
|
46
|
+
with self._path_locks_lock:
|
|
47
|
+
if path not in self._path_locks:
|
|
48
|
+
self._path_locks[path] = threading.Lock()
|
|
49
|
+
return self._path_locks[path]
|
|
50
|
+
|
|
51
|
+
def get(
|
|
52
|
+
self, path: str, fetch_func: Callable[[], dict[str, Any]]
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Get a secret, using cache if valid.
|
|
55
|
+
|
|
56
|
+
Uses per-path locking to prevent thundering herd: concurrent requests
|
|
57
|
+
for the same path wait for the first fetcher rather than all fetching.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: Full path to the secret, e.g. "database/creds/my-role".
|
|
61
|
+
fetch_func: A no-arg callable that returns the raw secret data dict
|
|
62
|
+
(with at least 'lease_duration' and 'expires_at' for cache control).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The raw secret data dictionary.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
VaultClientError: if the secret cannot be read.
|
|
69
|
+
"""
|
|
70
|
+
# Fast path: return cached value if valid (no lock needed for read-only)
|
|
71
|
+
with self._lock:
|
|
72
|
+
cached = self._cache.get(path)
|
|
73
|
+
if cached and not self._is_expiring_soon(cached):
|
|
74
|
+
return cached
|
|
75
|
+
|
|
76
|
+
# Slow path: acquire per-path lock to prevent thundering herd
|
|
77
|
+
path_lock = self._get_path_lock(path)
|
|
78
|
+
with path_lock:
|
|
79
|
+
# Re-check after acquiring lock — another thread may have fetched
|
|
80
|
+
with self._lock:
|
|
81
|
+
cached = self._cache.get(path)
|
|
82
|
+
if cached and not self._is_expiring_soon(cached):
|
|
83
|
+
return cached
|
|
84
|
+
|
|
85
|
+
# Fetch fresh (still holding path_lock to serialize concurrent fetchers)
|
|
86
|
+
data = fetch_func()
|
|
87
|
+
|
|
88
|
+
with self._lock:
|
|
89
|
+
# Evict oldest entry if at capacity
|
|
90
|
+
if self._max_size > 0 and len(self._cache) >= self._max_size:
|
|
91
|
+
oldest_path, _ = self._cache.popitem(last=False)
|
|
92
|
+
with self._path_locks_lock:
|
|
93
|
+
self._path_locks.pop(oldest_path, None)
|
|
94
|
+
self._cache[path] = data
|
|
95
|
+
return data
|
winwin_vault/client.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""VaultClient singleton with transparent token renewal."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import hvac
|
|
8
|
+
import hvac.exceptions
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .auth import detect_auth_method
|
|
12
|
+
from .cache import TTLCache
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
VaultClientError,
|
|
15
|
+
VaultConnectionError,
|
|
16
|
+
VaultSecretNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
from .model import AlicloudCredential, DatabaseCredential
|
|
19
|
+
|
|
20
|
+
_vault_client_lock = threading.Lock()
|
|
21
|
+
_vault_client_instance: "VaultClient | None" = None
|
|
22
|
+
|
|
23
|
+
# Exponential backoff between retry attempts (seconds)
|
|
24
|
+
_REAUTH_BACKOFF_BASE = 0.5
|
|
25
|
+
_REAUTH_BACKOFF_MULTIPLIER = 2
|
|
26
|
+
# Buffer seconds — skip lookup_self() call if token has more than this much TTL left
|
|
27
|
+
_TOKEN_TTL_BUFFER = 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VaultClient:
|
|
31
|
+
"""Singleton Vault client with transparent token renewal.
|
|
32
|
+
|
|
33
|
+
Use `VaultClient.from_env()` to get the singleton instance.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
kv_mount_point: str = "secret",
|
|
39
|
+
database_mount_point: str = "database",
|
|
40
|
+
alicloud_mount_point: str = "alicloud",
|
|
41
|
+
) -> None:
|
|
42
|
+
auth_handler = detect_auth_method()
|
|
43
|
+
self._client: hvac.Client = auth_handler.authenticate()
|
|
44
|
+
self._auth_handler = auth_handler
|
|
45
|
+
self._dynamic_cache: TTLCache = TTLCache()
|
|
46
|
+
self._reauth_lock = threading.Lock()
|
|
47
|
+
self._token_expires_at: float = 0.0
|
|
48
|
+
self._kv_mount_point = kv_mount_point
|
|
49
|
+
self._database_mount_point = database_mount_point
|
|
50
|
+
self._alicloud_mount_point = alicloud_mount_point
|
|
51
|
+
|
|
52
|
+
def set_kv_mount_point(self, mount_point: str) -> None:
|
|
53
|
+
"""Set the mount point for the KV v2 secrets engine."""
|
|
54
|
+
self._kv_mount_point = mount_point
|
|
55
|
+
|
|
56
|
+
def set_database_mount_point(self, mount_point: str) -> None:
|
|
57
|
+
"""Set the mount point for the database secrets engine."""
|
|
58
|
+
self._database_mount_point = mount_point
|
|
59
|
+
|
|
60
|
+
def set_alicloud_mount_point(self, mount_point: str) -> None:
|
|
61
|
+
"""Set the mount point for the Alicloud secrets engine."""
|
|
62
|
+
self._alicloud_mount_point = mount_point
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_env(cls) -> "VaultClient":
|
|
66
|
+
"""Get or create the singleton VaultClient instance.
|
|
67
|
+
|
|
68
|
+
Thread-safe lazy initialization.
|
|
69
|
+
"""
|
|
70
|
+
global _vault_client_instance
|
|
71
|
+
if _vault_client_instance is not None:
|
|
72
|
+
return _vault_client_instance
|
|
73
|
+
with _vault_client_lock:
|
|
74
|
+
if _vault_client_instance is None:
|
|
75
|
+
_vault_client_instance = cls()
|
|
76
|
+
return _vault_client_instance
|
|
77
|
+
|
|
78
|
+
def _is_token_valid(self) -> bool:
|
|
79
|
+
"""Return True if the current token has remaining TTL beyond the buffer.
|
|
80
|
+
|
|
81
|
+
Checks cached expiry without an API call when TTL is sufficient.
|
|
82
|
+
Falls back to lookup_self() when cached TTL is low.
|
|
83
|
+
"""
|
|
84
|
+
now = time.time()
|
|
85
|
+
if self._token_expires_at > now + _TOKEN_TTL_BUFFER:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
# Cached TTL ran out — do a real check and update cache
|
|
89
|
+
try:
|
|
90
|
+
result = self._client.auth.token.lookup_self()
|
|
91
|
+
ttl: int = result.get("data", {}).get("ttl", 0)
|
|
92
|
+
self._token_expires_at = now + ttl
|
|
93
|
+
return ttl > 0
|
|
94
|
+
except hvac.exceptions.VaultError:
|
|
95
|
+
# Auth error (401/403) — token is invalid, trigger re-auth
|
|
96
|
+
self._token_expires_at = 0.0
|
|
97
|
+
return False
|
|
98
|
+
except (requests.RequestException, OSError) as e:
|
|
99
|
+
# Network failure — Vault unreachable, fail fast without re-auth loop
|
|
100
|
+
raise VaultConnectionError(f"Vault is unreachable: {e}") from e
|
|
101
|
+
|
|
102
|
+
def _ensure_valid_token(self) -> bool:
|
|
103
|
+
"""Verify token is still valid, re-authenticating if invalid.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if token is valid (or was successfully re-authenticated).
|
|
107
|
+
"""
|
|
108
|
+
if self._is_token_valid():
|
|
109
|
+
return True
|
|
110
|
+
self._reauthenticate()
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def _reauthenticate(self) -> None:
|
|
114
|
+
"""Re-authenticate using the stored auth handler.
|
|
115
|
+
|
|
116
|
+
Thread-safe: uses a lock to prevent concurrent re-authentication
|
|
117
|
+
from different threads. Uses double-check pattern to avoid unnecessary
|
|
118
|
+
re-auth when another thread already re-authed.
|
|
119
|
+
"""
|
|
120
|
+
with self._reauth_lock:
|
|
121
|
+
# Re-check after acquiring lock — another thread may have re-authed
|
|
122
|
+
if self._is_token_valid():
|
|
123
|
+
return
|
|
124
|
+
self._client = self._auth_handler.authenticate()
|
|
125
|
+
# Verify the new token actually works before using it
|
|
126
|
+
if not self._is_token_valid():
|
|
127
|
+
raise VaultClientError(
|
|
128
|
+
"Re-authentication succeeded but token is still invalid"
|
|
129
|
+
)
|
|
130
|
+
# Auth succeeded — refresh token TTL cache
|
|
131
|
+
self._token_expires_at = 0.0
|
|
132
|
+
|
|
133
|
+
def _retry_with_renewal(self, func: Any, *args: Any, **kwargs: Any) -> Any:
|
|
134
|
+
"""Execute a Vault operation with token renewal on expiry.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
func: The function to call.
|
|
138
|
+
*args: Positional arguments to pass to func.
|
|
139
|
+
**kwargs: Keyword arguments to pass to func.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The result of func(*args, **kwargs).
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
VaultClientError: if the operation fails after token renewal.
|
|
146
|
+
"""
|
|
147
|
+
max_attempts = 2
|
|
148
|
+
last_exception: Exception | None = None
|
|
149
|
+
|
|
150
|
+
for attempt in range(max_attempts):
|
|
151
|
+
self._ensure_valid_token()
|
|
152
|
+
try:
|
|
153
|
+
return func(*args, **kwargs)
|
|
154
|
+
except (
|
|
155
|
+
VaultClientError,
|
|
156
|
+
hvac.exceptions.VaultError,
|
|
157
|
+
requests.RequestException,
|
|
158
|
+
OSError,
|
|
159
|
+
) as e:
|
|
160
|
+
last_exception = e
|
|
161
|
+
# If token is still valid, the failure is not auth-related — re-raise.
|
|
162
|
+
if self._is_token_valid():
|
|
163
|
+
raise
|
|
164
|
+
# Token was invalid — re-authenticate and retry once
|
|
165
|
+
if attempt < max_attempts - 1:
|
|
166
|
+
self._reauthenticate()
|
|
167
|
+
time.sleep(
|
|
168
|
+
_REAUTH_BACKOFF_BASE * (_REAUTH_BACKOFF_MULTIPLIER**attempt)
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
raise last_exception from None
|
|
172
|
+
|
|
173
|
+
# Defensive: should not reach here, but raise if it does
|
|
174
|
+
if last_exception is not None:
|
|
175
|
+
raise last_exception
|
|
176
|
+
raise VaultClientError("Unexpected error in retry loop")
|
|
177
|
+
|
|
178
|
+
def read_kv(self, path: str) -> dict[str, Any]:
|
|
179
|
+
"""Read a KV v2 secret.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
path: The path of the secret to read.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The secret data dictionary from the 'data' field.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
VaultSecretNotFoundError: if the secret path does not exist.
|
|
189
|
+
VaultClientError: if the read fails for any other reason.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def _do_read() -> dict[str, Any]:
|
|
193
|
+
try:
|
|
194
|
+
result = self._client.secrets.kv.v2.read_secret_version(
|
|
195
|
+
path=path, mount_point=self._kv_mount_point
|
|
196
|
+
)
|
|
197
|
+
return result["data"]["data"]
|
|
198
|
+
except hvac.exceptions.InvalidPath as e:
|
|
199
|
+
raise VaultSecretNotFoundError(
|
|
200
|
+
f"Secret not found at path '{self._kv_mount_point}/data/{path}': {e}"
|
|
201
|
+
) from e
|
|
202
|
+
|
|
203
|
+
return self._retry_with_renewal(_do_read)
|
|
204
|
+
|
|
205
|
+
def read_database_creds(self, path: str) -> DatabaseCredential:
|
|
206
|
+
"""Read a database dynamic secret with TTL caching.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
path: path to the dynamic secret, e.g. "my-role".
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A DatabaseCredential dataclass.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
VaultClientError: if the credential cannot be read.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def fetch() -> dict[str, Any]:
|
|
219
|
+
result = self._client.secrets.database.generate_credentials(
|
|
220
|
+
path, mount_point=self._database_mount_point
|
|
221
|
+
)
|
|
222
|
+
data = result.get("data", {})
|
|
223
|
+
username = data.get("username", "")
|
|
224
|
+
password = data.get("password", "")
|
|
225
|
+
if not username:
|
|
226
|
+
raise VaultClientError("Database credential response missing username")
|
|
227
|
+
if not password:
|
|
228
|
+
raise VaultClientError("Database credential response missing password")
|
|
229
|
+
lease_duration = result.get("lease_duration", 0)
|
|
230
|
+
now = time.time()
|
|
231
|
+
return {
|
|
232
|
+
"username": username,
|
|
233
|
+
"password": password,
|
|
234
|
+
"lease_duration": lease_duration,
|
|
235
|
+
"expires_at": (now + lease_duration) if lease_duration > 0 else 0.0,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
cache_key = f"{self._database_mount_point}/{path}"
|
|
239
|
+
data = self._retry_with_renewal(
|
|
240
|
+
lambda: self._dynamic_cache.get(cache_key, fetch)
|
|
241
|
+
)
|
|
242
|
+
return DatabaseCredential(
|
|
243
|
+
username=data["username"],
|
|
244
|
+
password=data["password"],
|
|
245
|
+
lease_duration=data["lease_duration"],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def read_alicloud_creds(self, path: str) -> AlicloudCredential:
|
|
249
|
+
"""Read an Alicloud STS dynamic secret with TTL caching.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
path: The path to the dynamic secret, e.g. "my-role".
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
An AlicloudCredential dataclass.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
VaultClientError: if the credential cannot be read.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def fetch() -> dict[str, Any]:
|
|
262
|
+
result = self._client.adapter.get(
|
|
263
|
+
f"v1/{self._alicloud_mount_point}/creds/{path}"
|
|
264
|
+
)
|
|
265
|
+
data = result.get("data", {})
|
|
266
|
+
for field in ("access_key", "secret_key", "security_token"):
|
|
267
|
+
if field not in data:
|
|
268
|
+
raise VaultClientError(
|
|
269
|
+
f"alicloud credential missing field: {field}"
|
|
270
|
+
)
|
|
271
|
+
lease_duration = result.get("lease_duration", 0)
|
|
272
|
+
now = time.time()
|
|
273
|
+
return {
|
|
274
|
+
"access_key": data["access_key"],
|
|
275
|
+
"secret_key": data["secret_key"],
|
|
276
|
+
"security_token": data["security_token"],
|
|
277
|
+
"lease_duration": lease_duration,
|
|
278
|
+
"expires_at": (now + lease_duration) if lease_duration > 0 else 0.0,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
cache_key = f"{self._alicloud_mount_point}/{path}"
|
|
282
|
+
data = self._retry_with_renewal(
|
|
283
|
+
lambda: self._dynamic_cache.get(cache_key, fetch)
|
|
284
|
+
)
|
|
285
|
+
return AlicloudCredential(
|
|
286
|
+
access_key=data["access_key"],
|
|
287
|
+
secret_key=data["secret_key"],
|
|
288
|
+
security_token=data["security_token"],
|
|
289
|
+
lease_duration=data["lease_duration"],
|
|
290
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Vault client exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class VaultClientError(Exception):
|
|
5
|
+
"""Base exception for all VaultClient errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VaultAuthenticationError(VaultClientError):
|
|
11
|
+
"""Raised when authentication fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VaultConnectionError(VaultClientError):
|
|
17
|
+
"""Raised when connection to Vault fails."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VaultSecretNotFoundError(VaultClientError):
|
|
23
|
+
"""Raised when a secret is not found."""
|
|
24
|
+
|
|
25
|
+
pass
|
winwin_vault/model.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Domain models for Vault credentials."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class DatabaseCredential:
|
|
8
|
+
"""A dynamic database credential returned from Vault."""
|
|
9
|
+
|
|
10
|
+
username: str
|
|
11
|
+
password: str
|
|
12
|
+
lease_duration: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AlicloudCredential:
|
|
17
|
+
"""A dynamic Alicloud STS credential returned from Vault."""
|
|
18
|
+
|
|
19
|
+
access_key: str
|
|
20
|
+
secret_key: str
|
|
21
|
+
security_token: str
|
|
22
|
+
lease_duration: int
|
winwin_vault/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: winwin-vault
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Ye Wenbin
|
|
6
|
+
Author-email: Ye Wenbin <yewenbin@brandct.com>
|
|
7
|
+
Requires-Dist: hvac>=2.0,<3.0
|
|
8
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
9
|
+
Requires-Dist: ruff>=0.15 ; extra == 'dev'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# winwin-vault
|
|
15
|
+
|
|
16
|
+
[](https://pypi.org/project/winwin-vault/)
|
|
17
|
+
[](LICENSE)
|
|
18
|
+
|
|
19
|
+
A read-only Python client for HashiCorp Vault, with automatic token renewal and dynamic secret caching.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install winwin-vault
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from winwin_vault import VaultClient
|
|
31
|
+
|
|
32
|
+
# Uses VAULT_ADDR and auth method from environment
|
|
33
|
+
client = VaultClient.from_env()
|
|
34
|
+
|
|
35
|
+
# Read a KV v2 secret
|
|
36
|
+
secret = client.read_kv("config")
|
|
37
|
+
print(secret["api_key"])
|
|
38
|
+
|
|
39
|
+
# Read database credentials (cached with TTL)
|
|
40
|
+
creds = client.read_database_creds("my-role")
|
|
41
|
+
print(creds.username, creds.password)
|
|
42
|
+
|
|
43
|
+
# Read Alicloud STS credentials (cached with TTL)
|
|
44
|
+
alicreds = client.read_alicloud_creds("my-role")
|
|
45
|
+
print(alicreds.access_key, alicreds.secret_key, alicreds.security_token)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Authentication
|
|
49
|
+
|
|
50
|
+
`VaultClient.from_env()` auto-detects the auth method:
|
|
51
|
+
|
|
52
|
+
| Environment | Auth Method |
|
|
53
|
+
|------------|-------------|
|
|
54
|
+
| `VAULT_TOKEN` set | `EnvTokenAuth` |
|
|
55
|
+
| `~/.vault-token` present | `EnvTokenAuth` |
|
|
56
|
+
| Kubernetes ServiceAccount | `KubernetesAuth` |
|
|
57
|
+
| Vault Agent socket | `VaultAgentAuth` |
|
|
58
|
+
|
|
59
|
+
TLS is enforced for all auth methods except `VaultAgentAuth` (which communicates over a local Unix socket).
|
|
60
|
+
|
|
61
|
+
## Mount Points
|
|
62
|
+
|
|
63
|
+
Default mount points:
|
|
64
|
+
|
|
65
|
+
- KV v2: `secret`
|
|
66
|
+
- Database: `database`
|
|
67
|
+
- Alicloud: `alicloud`
|
|
68
|
+
|
|
69
|
+
Change via setters:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
client.set_kv_mount_point("internal")
|
|
73
|
+
client.set_database_mount_point("database")
|
|
74
|
+
client.set_alicloud_mount_point("alicloud")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Exception Handling
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from winwin_vault import VaultClient, VaultClientError, VaultSecretNotFoundError
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
secret = client.read_kv("config")
|
|
84
|
+
except VaultSecretNotFoundError:
|
|
85
|
+
print("Secret not found")
|
|
86
|
+
except VaultClientError as e:
|
|
87
|
+
print(f"Vault error: {e}")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT License — see [LICENSE](LICENSE) file to be added.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
winwin_vault/__init__.py,sha256=pkR7Bo5iISW4NwSgvplA9Y5gCES_1zZgZH3hRj-x320,484
|
|
2
|
+
winwin_vault/auth.py,sha256=GTr3JcnR12wIdAsgOrcJiCBiLoyv-vScmriG16WL0Ro,5471
|
|
3
|
+
winwin_vault/cache.py,sha256=OPMH0csD2xZCMLv6V4EziXoQBeimTlXb7_L0llWxPqQ,3793
|
|
4
|
+
winwin_vault/client.py,sha256=O2FYkYv8593cfuTBHZ50TM-NI-KCmLG7pgLZpPM2rk8,10629
|
|
5
|
+
winwin_vault/exceptions.py,sha256=wX83n5SNgyDnciOPKOWao2fRphK4sECqxjut-ds-9rc,451
|
|
6
|
+
winwin_vault/model.py,sha256=1VdimvOmIC1P2LoIbgqgeOYmKud9jCaJ-w8xg3cv8t4,458
|
|
7
|
+
winwin_vault/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
winwin_vault-0.1.0.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
|
|
9
|
+
winwin_vault-0.1.0.dist-info/METADATA,sha256=QwBMdk98uqEg2olsGO4CVLDekGOb7Smo7wAMZRXQwfE,2291
|
|
10
|
+
winwin_vault-0.1.0.dist-info/RECORD,,
|