secrefs 0.2.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,156 @@
1
+ """
2
+ AWS Secrets Manager provider. Two credential-sourcing modes:
3
+
4
+ - **Ambient (default)**: boto3's default credential resolution chain -
5
+ environment variables, shared config/credentials files, ECS/EC2 instance
6
+ metadata, or an assumed IAM role - so no credentials ever need to live in
7
+ SecRefs configuration itself. One client is built lazily and reused for
8
+ the provider's lifetime.
9
+ - **Control-plane-sourced** (`control_plane` option): a fresh,
10
+ request-scoped credential is minted per fetch via the control plane's
11
+ `/v1/credentials/mint`, so a distinct boto3 client is constructed per
12
+ path rather than reused - each one only ever carries the narrow scope
13
+ that one mint granted.
14
+
15
+ boto3 is synchronous, so calls are offloaded to a thread via
16
+ `asyncio.to_thread` to keep the async provider interface non-blocking.
17
+
18
+ The ambient client is constructed lazily on first use - boto3.client()
19
+ resolves (and validates) the region eagerly at construction time, raising
20
+ NoRegionError immediately if none is configured anywhere, so building it in
21
+ __init__ would mean simply having an AWSSecretsManagerProvider in your
22
+ registry - even one you never reference via sec://aws/... - breaks the
23
+ whole import in any region-less environment. Matches VaultProvider's own
24
+ lazy-construction rationale.
25
+
26
+ Fetched values are re-fetched on every expansion by default; see
27
+ `cache_ttl_ms` and ../ttl_cache.py for why that default is what it is.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import os
34
+ from typing import Any, Optional, cast
35
+
36
+ import boto3
37
+
38
+ from ..control_plane_client import (
39
+ ControlPlaneClient,
40
+ ControlPlaneCredentialSource,
41
+ ControlPlaneRequestError,
42
+ MintedAWSCredentials,
43
+ )
44
+ from ..ttl_cache import TtlCache
45
+ from .base import ProviderHealth, SecretFetchRequest, SecretProvider, extract_field
46
+
47
+
48
+ class AWSSecretsManagerProvider(SecretProvider):
49
+ name = "aws"
50
+
51
+ def __init__(
52
+ self,
53
+ region: Optional[str] = None,
54
+ client: Optional[Any] = None,
55
+ control_plane: Optional[ControlPlaneCredentialSource] = None,
56
+ cache_ttl_ms: float = 0.0,
57
+ ) -> None:
58
+ """`client` injects a pre-configured boto3 client (primarily for
59
+ testing) and wins over `control_plane` if both are given, since a
60
+ test that supplies an explicit client wants full control regardless
61
+ of the mode. `cache_ttl_ms` is how long a fetched secret value may
62
+ be reused; it defaults to 0, meaning every expansion re-fetches, so
63
+ a rotated secret reaches a long-running consumer without a
64
+ redeploy."""
65
+ self._explicit_client = client
66
+ self._client_instance: Optional[Any] = None
67
+ self._region = region
68
+ self._control_plane = control_plane
69
+ self._control_plane_client: Optional[ControlPlaneClient] = None
70
+ if control_plane is not None:
71
+ self._control_plane_client = control_plane.client or ControlPlaneClient(
72
+ base_url=control_plane.base_url, token=control_plane.token
73
+ )
74
+ self._raw_cache: TtlCache[str] = TtlCache(ttl_ms=cache_ttl_ms)
75
+
76
+ def _region_name(self) -> Optional[str]:
77
+ return self._region or os.environ.get("AWS_REGION")
78
+
79
+ async def _client_for(self, path: str) -> Any:
80
+ """Resolves the boto3 client to use for one `path` - lazily built and
81
+ reused in ambient mode, freshly minted per call in control-plane
82
+ mode. An explicitly injected client always wins."""
83
+ if self._explicit_client is not None:
84
+ return self._explicit_client
85
+
86
+ if self._control_plane is not None and self._control_plane_client is not None:
87
+ minted = await self._control_plane_client.mint_credential(
88
+ self._control_plane.alias, path
89
+ )
90
+ credentials = minted.credentials
91
+ if not isinstance(credentials, MintedAWSCredentials):
92
+ raise ValueError(
93
+ f'control plane returned a "{minted.provider}" credential for alias '
94
+ f'"{self._control_plane.alias}", expected "aws"'
95
+ )
96
+ return boto3.client(
97
+ "secretsmanager",
98
+ region_name=self._region_name(),
99
+ aws_access_key_id=credentials.access_key_id,
100
+ aws_secret_access_key=credentials.secret_access_key,
101
+ aws_session_token=credentials.session_token,
102
+ )
103
+
104
+ if self._client_instance is None:
105
+ self._client_instance = boto3.client("secretsmanager", region_name=self._region_name())
106
+ return self._client_instance
107
+
108
+ async def _fetch_raw(self, path: str) -> str:
109
+ try:
110
+ client = await self._client_for(path)
111
+ response = await asyncio.to_thread(client.get_secret_value, SecretId=path)
112
+ except Exception as exc: # noqa: BLE001 - re-raised with context below
113
+ raise ValueError(f'could not fetch secret "{path}": {exc}') from exc
114
+
115
+ if response.get("SecretString") is not None:
116
+ return cast(str, response["SecretString"])
117
+ if response.get("SecretBinary") is not None:
118
+ raw = response["SecretBinary"]
119
+ return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
120
+ raise ValueError(f'secret "{path}" has no SecretString or SecretBinary payload')
121
+
122
+ async def fetch_one(self, request: SecretFetchRequest) -> str:
123
+ # Keyed by path, not by path+field, so several `#field` references
124
+ # against the same secret still cost one call (and, in control-plane
125
+ # mode, one mint) when they're expanded together.
126
+ raw = await self._raw_cache.fetch(request.path, lambda: self._fetch_raw(request.path))
127
+ return extract_field(raw, request.field, provider=self.name, path=request.path)
128
+
129
+ async def health_check(self) -> ProviderHealth:
130
+ try:
131
+ if self._control_plane is not None and self._control_plane_client is not None:
132
+ # A control-plane-sourced provider has no single ambient
133
+ # credential to probe - health here means "the control plane
134
+ # is reachable and this token is accepted", checked with a
135
+ # deliberately-unresolvable synthetic path so this never
136
+ # mutates anything or depends on any specific secret
137
+ # existing. A 403 ("no grant authorizes...") still proves
138
+ # reachability + auth worked; only a network/5xx failure
139
+ # means unhealthy.
140
+ try:
141
+ await self._control_plane_client.mint_credential(
142
+ self._control_plane.alias, "__secrefs_health_check__"
143
+ )
144
+ except ControlPlaneRequestError:
145
+ return ProviderHealth(
146
+ provider=self.name, ok=True, message="control plane reachable"
147
+ )
148
+ return ProviderHealth(provider=self.name, ok=True)
149
+
150
+ # A cheap, low-privilege call that proves both network
151
+ # reachability and that ambient credentials are valid.
152
+ client = await self._client_for("__secrefs_health_check__")
153
+ await asyncio.to_thread(client.list_secrets, MaxResults=1)
154
+ return ProviderHealth(provider=self.name, ok=True)
155
+ except Exception as exc: # noqa: BLE001
156
+ return ProviderHealth(provider=self.name, ok=False, message=str(exc))
@@ -0,0 +1,89 @@
1
+ """
2
+ The provider contract every SecRefs backend implements. Providers never
3
+ log, print, or persist the values they return - that discipline is
4
+ enforced by the resolver/CLI layers above them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+ from typing import Any, List, Optional
14
+
15
+ _MISSING = object()
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SecretFetchRequest:
20
+ path: str
21
+ field: Optional[str] = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ProviderHealth:
26
+ provider: str
27
+ ok: bool
28
+ message: Optional[str] = None # human-readable diagnostic; never secret material
29
+
30
+
31
+ class SecretFetchError(Exception):
32
+ def __init__(self, provider: str, path: str, cause: object) -> None:
33
+ self.provider = provider
34
+ self.path = path
35
+ super().__init__(f'[{provider}] failed to fetch secret at "{path}": {cause}')
36
+
37
+
38
+ class SecretProvider(ABC):
39
+ name: str
40
+
41
+ @abstractmethod
42
+ async def fetch_one(self, request: SecretFetchRequest) -> str: ...
43
+
44
+ async def fetch_batch(self, requests: List[SecretFetchRequest]) -> List[str]:
45
+ """
46
+ Default implementation: concurrent individual fetches, surfacing the
47
+ first failure with full context. Providers may override this to use
48
+ a backend's native batch API.
49
+ """
50
+ results = await asyncio.gather(
51
+ *(self.fetch_one(r) for r in requests), return_exceptions=True
52
+ )
53
+ values: List[str] = []
54
+ for request, result in zip(requests, results):
55
+ if isinstance(result, BaseException):
56
+ raise SecretFetchError(self.name, request.path, result) from result
57
+ values.append(result)
58
+ return values
59
+
60
+ @abstractmethod
61
+ async def health_check(self) -> ProviderHealth: ...
62
+
63
+
64
+ def extract_field(raw: str, field: Optional[str], *, provider: str, path: str) -> str:
65
+ """
66
+ Extracts a (possibly dot-nested) field from a JSON-encoded secret. If no
67
+ field is requested, `raw` is returned unchanged.
68
+ """
69
+ if not field:
70
+ return raw
71
+
72
+ try:
73
+ parsed: Any = json.loads(raw)
74
+ except json.JSONDecodeError as exc:
75
+ raise ValueError(
76
+ f'[{provider}] secret at "{path}" is not JSON, cannot extract field "{field}"'
77
+ ) from exc
78
+
79
+ current: Any = parsed
80
+ for part in field.split("."):
81
+ if not isinstance(current, dict):
82
+ raise ValueError(f'[{provider}] field "{field}" not found in secret at "{path}"')
83
+ current = current.get(part, _MISSING)
84
+ if current is _MISSING:
85
+ raise ValueError(f'[{provider}] field "{field}" not found in secret at "{path}"')
86
+
87
+ if isinstance(current, (dict, list)):
88
+ return json.dumps(current)
89
+ return str(current)
@@ -0,0 +1,257 @@
1
+ """
2
+ Bitwarden **Secrets Manager** provider (not the password vault - see
3
+ https://bitwarden.com/help/secrets-manager-overview/). Two structural
4
+ differences from AWSSecretsManagerProvider/VaultProvider worth knowing
5
+ before using this:
6
+
7
+ 1. **Secrets are end-to-end encrypted.** There is no plain authenticated
8
+ REST call to fetch a value - the official SDK derives a decryption key
9
+ from the access token during login and decrypts client-side. That's why
10
+ this provider depends on `bitwarden-sdk` (Bitwarden's own Python binding
11
+ over their Rust SDK) rather than a bare HTTP request.
12
+ 2. **Bitwarden addresses secrets by UUID, with no path hierarchy** the way
13
+ AWS/Vault secret names have. `path` may be that UUID directly, or - if
14
+ `organization_id` is configured (ambient mode) or supplied by the control
15
+ plane (control-plane mode) - a human-readable secret *name* (Bitwarden's
16
+ "key" field), resolved via one cached `secrets().list()` call. With
17
+ neither, only UUID paths work.
18
+
19
+ `bitwarden-sdk` is an optional dependency (`pip install 'secrefs[bitwarden]'`)
20
+ rather than a required one, unlike boto3 and hvac: it ships as prebuilt
21
+ native wheels for a fixed set of platform tags with no source distribution
22
+ to fall back on, so requiring it would break installing SecRefs at all on
23
+ any platform Bitwarden doesn't publish a wheel for - including for the
24
+ majority of users who never write a sec://bitwarden/... reference. It's
25
+ therefore imported at first use rather than at module import, so having a
26
+ BitwardenProvider in the default registry costs nothing until something
27
+ actually resolves through it.
28
+
29
+ The SDK is synchronous, so its calls are offloaded to a thread via
30
+ `asyncio.to_thread`, the same way boto3 and hvac calls are.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import os
37
+ import re
38
+ from typing import Any, Dict, Optional
39
+
40
+ from ..control_plane_client import (
41
+ ControlPlaneClient,
42
+ ControlPlaneCredentialSource,
43
+ MintedBitwardenCredentials,
44
+ )
45
+ from .base import ProviderHealth, SecretFetchRequest, SecretProvider, extract_field
46
+
47
+ UUID_PATTERN = re.compile(
48
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
49
+ )
50
+
51
+
52
+ def _unwrap(response: Any, context: str) -> Any:
53
+ """Pulls `data` out of the SDK's `{success, data, errorMessage}` response
54
+ envelope. The SDK itself already raises when `success` is false, so this
55
+ is the belt to that's braces - but the envelope types `data` as optional,
56
+ and an empty one would otherwise surface as an AttributeError rather than
57
+ as whatever the SDK put in `errorMessage`."""
58
+ data = getattr(response, "data", None)
59
+ if data is None:
60
+ message = getattr(response, "error_message", None) or "no data returned"
61
+ raise ValueError(f"{context}: {message}")
62
+ return data
63
+
64
+
65
+ class BitwardenProvider(SecretProvider):
66
+ name = "bitwarden"
67
+
68
+ def __init__(
69
+ self,
70
+ access_token: Optional[str] = None,
71
+ organization_id: Optional[str] = None,
72
+ api_url: Optional[str] = None,
73
+ identity_url: Optional[str] = None,
74
+ state_file: Optional[str] = None,
75
+ control_plane: Optional[ControlPlaneCredentialSource] = None,
76
+ client: Optional[Any] = None,
77
+ ) -> None:
78
+ """`access_token` defaults to $BWS_ACCESS_TOKEN and `organization_id`
79
+ to $BWS_ORGANIZATION_ID; both are ignored when `control_plane` is
80
+ set, which supplies them per request instead. `api_url`/
81
+ `identity_url` (defaulting to $BWS_API_URL/$BWS_IDENTITY_URL) point
82
+ at a self-hosted instance.
83
+
84
+ `state_file` is an opt-in path to an encrypted session-state file the
85
+ SDK can reuse across calls to reduce auth rate-limiting (Bitwarden's
86
+ own docs describe this file's contents as fully encrypted, not
87
+ plaintext secret material). Omitted by default - this provider
88
+ authenticates in memory and writes nothing to disk unless a caller
89
+ opts in.
90
+
91
+ Setting `control_plane` sources the access token and organization id
92
+ from a running control plane (docs/control-plane-design.md §7/§10,
93
+ and §8 for why Bitwarden's distribution here isn't the same as AWS's
94
+ per-request minting). Every fetch still requests a distribution for
95
+ its specific `path`, so the control plane's RBAC Grant.path_pattern
96
+ is enforced per secret even though the underlying Bitwarden token
97
+ itself isn't scoped that narrowly - "SDK-side enforcement" as
98
+ documented on the control-plane side."""
99
+ self._explicit_client = client
100
+ self._client_instance: Optional[Any] = None
101
+ self._api_url = api_url or os.environ.get("BWS_API_URL")
102
+ self._identity_url = identity_url or os.environ.get("BWS_IDENTITY_URL")
103
+ self._state_file = state_file
104
+ self._control_plane = control_plane
105
+ self._control_plane_client: Optional[ControlPlaneClient] = None
106
+
107
+ self._ambient_access_token: Optional[str] = None
108
+ self._ambient_organization_id: Optional[str] = None
109
+ self._organization_id: Optional[str] = None
110
+
111
+ if control_plane is not None:
112
+ self._control_plane_client = control_plane.client or ControlPlaneClient(
113
+ base_url=control_plane.base_url, token=control_plane.token
114
+ )
115
+ else:
116
+ self._ambient_access_token = access_token or os.environ.get("BWS_ACCESS_TOKEN")
117
+ self._ambient_organization_id = organization_id or os.environ.get(
118
+ "BWS_ORGANIZATION_ID"
119
+ )
120
+ self._organization_id = self._ambient_organization_id
121
+
122
+ self._logged_in_token: Optional[str] = None
123
+ # Secret name -> id, populated by one list() call the first time a
124
+ # non-UUID path is requested. Dropped if organization_id ever changes
125
+ # (control-plane mode, defensively - static in practice).
126
+ self._name_to_id: Optional[Dict[str, str]] = None
127
+ # Serializes login and the name->id lookup so N concurrent
128
+ # expansions authenticate once and list once rather than N times.
129
+ self._login_lock = asyncio.Lock()
130
+ self._list_lock = asyncio.Lock()
131
+
132
+ def _get_client(self) -> Any:
133
+ if self._explicit_client is not None:
134
+ return self._explicit_client
135
+ if self._client_instance is not None:
136
+ return self._client_instance
137
+
138
+ try:
139
+ from bitwarden_sdk import BitwardenClient, ClientSettings
140
+ except ImportError as exc:
141
+ raise ValueError(
142
+ "the bitwarden-sdk package is required for sec://bitwarden/... references "
143
+ "(pip install 'secrefs[bitwarden]') - Bitwarden secrets are end-to-end "
144
+ "encrypted and can only be decrypted by their own SDK"
145
+ ) from exc
146
+
147
+ # Passing no settings at all when neither URL is overridden, rather
148
+ # than a settings object full of Nones, so the SDK's own defaults
149
+ # (bitwarden.com) stay the single source of that truth.
150
+ if self._api_url or self._identity_url:
151
+ self._client_instance = BitwardenClient(
152
+ ClientSettings(api_url=self._api_url, identity_url=self._identity_url)
153
+ )
154
+ else:
155
+ self._client_instance = BitwardenClient()
156
+ return self._client_instance
157
+
158
+ async def _login_with(self, access_token: str, organization_id: Optional[str]) -> None:
159
+ async with self._login_lock:
160
+ if organization_id != self._organization_id:
161
+ self._name_to_id = None # stale map keyed to a now-superseded org
162
+ self._organization_id = organization_id
163
+ if self._logged_in_token == access_token:
164
+ return
165
+
166
+ client = self._get_client()
167
+ try:
168
+ await asyncio.to_thread(
169
+ client.auth().login_access_token, access_token, self._state_file
170
+ )
171
+ except Exception as exc: # noqa: BLE001 - re-raised with context below
172
+ raise ValueError(
173
+ f"could not authenticate with the given access token: {exc}"
174
+ ) from exc
175
+ # Recorded only after a successful login, so a failure is retried
176
+ # rather than remembered as a session that exists.
177
+ self._logged_in_token = access_token
178
+
179
+ async def _ensure_logged_in_for(self, path: str) -> None:
180
+ """Ensures a session exists for `path`. Ambient mode logs in once
181
+ with the ambient token; control-plane mode requests a distribution
182
+ for this specific `path` every call - see the `control_plane`
183
+ constructor argument for why that RBAC check has to be per-path even
184
+ though the token it returns doesn't vary."""
185
+ if self._control_plane is None or self._control_plane_client is None:
186
+ if not self._ambient_access_token:
187
+ raise ValueError(
188
+ "BWS_ACCESS_TOKEN is not set (required for sec://bitwarden/... references)"
189
+ )
190
+ await self._login_with(self._ambient_access_token, self._ambient_organization_id)
191
+ return
192
+
193
+ minted = await self._control_plane_client.mint_credential(self._control_plane.alias, path)
194
+ credentials = minted.credentials
195
+ if not isinstance(credentials, MintedBitwardenCredentials):
196
+ raise ValueError(
197
+ f'control plane returned a "{minted.provider}" credential for alias '
198
+ f'"{self._control_plane.alias}", expected "bitwarden"'
199
+ )
200
+ await self._login_with(credentials.access_token, credentials.organization_id)
201
+
202
+ async def _resolve_secret_id(self, path: str) -> str:
203
+ """Assumes _ensure_logged_in_for(path) has already run for this exact
204
+ `path` - callers always do that first, so self._organization_id is
205
+ already whatever this path's session resolved to."""
206
+ if UUID_PATTERN.match(path):
207
+ return path
208
+
209
+ if not self._organization_id:
210
+ raise ValueError(
211
+ f'"{path}" is not a secret UUID, and no organization_id is available to look up '
212
+ "a secret by name (set BWS_ORGANIZATION_ID, use the UUID directly, or - in "
213
+ "control-plane mode - the distributed credential didn't include one)"
214
+ )
215
+
216
+ async with self._list_lock:
217
+ if self._name_to_id is None:
218
+ organization_id = self._organization_id
219
+ response = await asyncio.to_thread(
220
+ self._get_client().secrets().list, organization_id
221
+ )
222
+ listed = _unwrap(response, f'could not list secrets in "{organization_id}"')
223
+ self._name_to_id = {secret.key: str(secret.id) for secret in listed.data}
224
+ name_to_id = self._name_to_id
225
+
226
+ secret_id = name_to_id.get(path)
227
+ if secret_id is None:
228
+ raise ValueError(
229
+ f'no secret named "{path}" found in organization "{self._organization_id}"'
230
+ )
231
+ return secret_id
232
+
233
+ async def fetch_one(self, request: SecretFetchRequest) -> str:
234
+ try:
235
+ # One _ensure_logged_in_for per fetch - in control-plane mode
236
+ # this is the one distribution/RBAC-gate call for this path, and
237
+ # _resolve_secret_id below relies on it having already set
238
+ # self._organization_id.
239
+ await self._ensure_logged_in_for(request.path)
240
+ secret_id = await self._resolve_secret_id(request.path)
241
+ response = await asyncio.to_thread(self._get_client().secrets().get, secret_id)
242
+ secret = _unwrap(response, f'could not read secret "{request.path}"')
243
+ value: str = secret.value
244
+ except Exception as exc: # noqa: BLE001 - re-raised with context below
245
+ raise ValueError(f'could not fetch secret "{request.path}": {exc}') from exc
246
+
247
+ # Deliberately outside the try: a missing #field is a reference
248
+ # problem, not a fetch failure, and extract_field's message already
249
+ # names the provider and path.
250
+ return extract_field(value, request.field, provider=self.name, path=request.path)
251
+
252
+ async def health_check(self) -> ProviderHealth:
253
+ try:
254
+ await self._ensure_logged_in_for("__secrefs_health_check__")
255
+ return ProviderHealth(provider=self.name, ok=True)
256
+ except Exception as exc: # noqa: BLE001
257
+ return ProviderHealth(provider=self.name, ok=False, message=str(exc))
@@ -0,0 +1,79 @@
1
+ """
2
+ Reads secrets from a gitignored, developer-local JSON file. Intended for
3
+ local development only.
4
+
5
+ Example `.secrefs.local.json`:
6
+ { "mock-db": { "password": "hunter2", "user": "postgres" } }
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Optional, Union
15
+
16
+ from .base import ProviderHealth, SecretFetchRequest, SecretProvider, extract_field
17
+
18
+ DEFAULT_FILENAME = ".secrefs.local.json"
19
+
20
+
21
+ class LocalProvider(SecretProvider):
22
+ name = "local"
23
+
24
+ def __init__(
25
+ self,
26
+ file_path: Optional[Union[str, Path]] = None,
27
+ cache_file: bool = False,
28
+ ) -> None:
29
+ """`cache_file` keeps the parsed file in memory instead of re-reading
30
+ it per fetch. Off by default so an edit takes effect immediately -
31
+ caching it meant editing .secrefs.local.json mid-session silently did
32
+ nothing, which is the local-development shape of the same
33
+ stale-secret problem ../ttl_cache.py exists to solve."""
34
+ self._file_path = Path(
35
+ file_path or os.environ.get("SECREFS_LOCAL_FILE") or (Path.cwd() / DEFAULT_FILENAME)
36
+ )
37
+ self._cache_file = cache_file
38
+ self._cache: Optional[Dict[str, Any]] = None
39
+
40
+ def _load(self) -> Dict[str, Any]:
41
+ # The file is local and tiny, so re-reading it costs nothing worth
42
+ # trading an edit-takes-effect guarantee for.
43
+ if self._cache is not None and self._cache_file:
44
+ return self._cache
45
+
46
+ try:
47
+ raw = self._file_path.read_text(encoding="utf-8")
48
+ except OSError as exc:
49
+ raise ValueError(
50
+ f'[local] could not read local secrets file at "{self._file_path}": {exc}. '
51
+ "This file is gitignored by convention - see .secrefs.local.json in .gitignore."
52
+ ) from exc
53
+
54
+ try:
55
+ parsed = json.loads(raw)
56
+ except json.JSONDecodeError as exc:
57
+ raise ValueError(f'[local] "{self._file_path}" is not valid JSON: {exc}') from exc
58
+
59
+ if not isinstance(parsed, dict):
60
+ raise ValueError(f'[local] "{self._file_path}" must contain a top-level JSON object')
61
+
62
+ self._cache = parsed
63
+ return self._cache
64
+
65
+ async def fetch_one(self, request: SecretFetchRequest) -> str:
66
+ data = self._load()
67
+ if request.path not in data:
68
+ raise ValueError(f'[local] no entry for path "{request.path}" in {self._file_path}')
69
+
70
+ entry = data[request.path]
71
+ raw = entry if isinstance(entry, str) else json.dumps(entry)
72
+ return extract_field(raw, request.field, provider=self.name, path=request.path)
73
+
74
+ async def health_check(self) -> ProviderHealth:
75
+ try:
76
+ self._load()
77
+ return ProviderHealth(provider=self.name, ok=True, message=str(self._file_path))
78
+ except Exception as exc: # noqa: BLE001 - surfaced to the caller as a health message
79
+ return ProviderHealth(provider=self.name, ok=False, message=str(exc))
@@ -0,0 +1,99 @@
1
+ """
2
+ HashiCorp Vault provider supporting both KV v1 and KV v2 secrets engines.
3
+ Auth is ambient via VAULT_ADDR/VAULT_TOKEN - point `path` at whatever the
4
+ Vault HTTP API itself expects (KV v2 mounts include a literal `data/`
5
+ segment, e.g. `secret/data/stripe`; KV v1 mounts do not).
6
+
7
+ hvac is synchronous, so calls are offloaded to a thread via
8
+ `asyncio.to_thread`. The client is constructed lazily on first use so that
9
+ simply having a VaultProvider in your registry doesn't require Vault to be
10
+ configured if you never reference sec://vault/... at all.
11
+
12
+ Read secrets are re-read on every expansion by default; see `cache_ttl_ms`
13
+ and ../ttl_cache.py for why that default is what it is.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import os
21
+ from typing import Any, Dict, Optional, cast
22
+
23
+ import hvac
24
+
25
+ from ..ttl_cache import TtlCache
26
+ from .base import ProviderHealth, SecretFetchRequest, SecretProvider, extract_field
27
+
28
+
29
+ class VaultProvider(SecretProvider):
30
+ name = "vault"
31
+
32
+ def __init__(
33
+ self,
34
+ url: Optional[str] = None,
35
+ token: Optional[str] = None,
36
+ client: Optional[Any] = None,
37
+ cache_ttl_ms: float = 0.0,
38
+ ) -> None:
39
+ """`cache_ttl_ms` is how long a read secret may be reused; it
40
+ defaults to 0, meaning every expansion re-reads, so rotation reaches
41
+ a long-running consumer without a redeploy."""
42
+ self._explicit_client = client
43
+ self._url = url or os.environ.get("VAULT_ADDR")
44
+ self._token = token or os.environ.get("VAULT_TOKEN")
45
+ self._client_instance: Optional[Any] = None
46
+ self._data_cache: TtlCache[Dict[str, Any]] = TtlCache(ttl_ms=cache_ttl_ms)
47
+
48
+ def _get_client(self) -> Any:
49
+ if self._explicit_client is not None:
50
+ return self._explicit_client
51
+ if self._client_instance is not None:
52
+ return self._client_instance
53
+
54
+ if not self._url:
55
+ raise ValueError("VAULT_ADDR is not set (required for sec://vault/... references)")
56
+ if not self._token:
57
+ raise ValueError("VAULT_TOKEN is not set (required for sec://vault/... references)")
58
+
59
+ self._client_instance = hvac.Client(url=self._url, token=self._token)
60
+ return self._client_instance
61
+
62
+ async def _fetch_data(self, path: str) -> Dict[str, Any]:
63
+ # Outside the try: an unconfigured VAULT_ADDR/VAULT_TOKEN is a
64
+ # misconfiguration, not a failed read, and shouldn't be reported as
65
+ # one.
66
+ client = self._get_client()
67
+ try:
68
+ response = await asyncio.to_thread(client.read, path)
69
+ except Exception as exc: # noqa: BLE001
70
+ raise ValueError(f'could not read Vault path "{path}": {exc}') from exc
71
+
72
+ if response is None or "data" not in response:
73
+ raise ValueError(f'no data returned for path "{path}"')
74
+
75
+ outer = response["data"]
76
+ # KV v2 responses nest the secret under data.data alongside
77
+ # data.metadata; KV v1 responses put the secret straight in data.
78
+ if isinstance(outer, dict) and "data" in outer and "metadata" in outer:
79
+ return cast(Dict[str, Any], outer["data"])
80
+ return cast(Dict[str, Any], outer)
81
+
82
+ async def fetch_one(self, request: SecretFetchRequest) -> str:
83
+ data = await self._data_cache.fetch(request.path, lambda: self._fetch_data(request.path))
84
+
85
+ if not request.field:
86
+ if len(data) == 1:
87
+ (only,) = data.values()
88
+ return only if isinstance(only, str) else json.dumps(only)
89
+ return json.dumps(data)
90
+
91
+ return extract_field(json.dumps(data), request.field, provider=self.name, path=request.path)
92
+
93
+ async def health_check(self) -> ProviderHealth:
94
+ try:
95
+ client = self._get_client()
96
+ await asyncio.to_thread(client.sys.read_health_status)
97
+ return ProviderHealth(provider=self.name, ok=True)
98
+ except Exception as exc: # noqa: BLE001
99
+ return ProviderHealth(provider=self.name, ok=False, message=str(exc))