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.
- secrefs/__init__.py +110 -0
- secrefs/cli.py +140 -0
- secrefs/control_plane_client.py +206 -0
- secrefs/envfile.py +79 -0
- secrefs/parser.py +70 -0
- secrefs/providers/__init__.py +16 -0
- secrefs/providers/aws.py +156 -0
- secrefs/providers/base.py +89 -0
- secrefs/providers/bitwarden.py +257 -0
- secrefs/providers/local.py +79 -0
- secrefs/providers/vault.py +99 -0
- secrefs/resolver.py +138 -0
- secrefs/ttl_cache.py +109 -0
- secrefs-0.2.0.dist-info/METADATA +127 -0
- secrefs-0.2.0.dist-info/RECORD +18 -0
- secrefs-0.2.0.dist-info/WHEEL +4 -0
- secrefs-0.2.0.dist-info/entry_points.txt +3 -0
- secrefs-0.2.0.dist-info/licenses/LICENSE +21 -0
secrefs/providers/aws.py
ADDED
|
@@ -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))
|