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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/winwin-vault)](https://pypi.org/project/winwin-vault/)
17
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any