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/resolver.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Dict, List, Mapping, Optional, Tuple
7
+
8
+ from .parser import ParsedSecretRef, is_secret_ref, parse_secret_ref
9
+ from .providers.base import SecretFetchRequest, SecretProvider
10
+
11
+ ProviderRegistry = Mapping[str, SecretProvider]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ResolutionFailure:
16
+ key: str
17
+ ref: str
18
+ message: str
19
+
20
+
21
+ class SecRefsResolutionError(Exception):
22
+ def __init__(self, errors: List[ResolutionFailure]) -> None:
23
+ self.errors = errors
24
+ lines = "\n".join(f" - {e.key}: {e.ref} -> {e.message}" for e in errors)
25
+ super().__init__(f"Failed to resolve {len(errors)} secret reference(s):\n{lines}")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CheckResult:
30
+ key: str
31
+ ref: str
32
+ provider: str
33
+ ok: bool
34
+ message: Optional[str] = None # present only when ok is False; never a secret value
35
+
36
+
37
+ async def _resolve_one(ref: ParsedSecretRef, providers: ProviderRegistry) -> str:
38
+ provider = providers.get(ref.provider)
39
+ if provider is None:
40
+ available = ", ".join(providers.keys()) or "none configured"
41
+ raise ValueError(f'unknown provider "{ref.provider}" (available: {available})')
42
+ return await provider.fetch_one(SecretFetchRequest(path=ref.path, field=ref.field))
43
+
44
+
45
+ async def expand_key_value_map(
46
+ input_map: Mapping[str, Optional[str]],
47
+ providers: ProviderRegistry,
48
+ *,
49
+ strict: bool = True,
50
+ ) -> Dict[str, str]:
51
+ """
52
+ Expands every `sec://` value in a plain key/value map, resolving all
53
+ references concurrently. Non-reference values pass through untouched.
54
+ Raises SecRefsResolutionError aggregating every failed reference.
55
+ """
56
+ output: Dict[str, str] = {}
57
+ pending: List[Tuple[str, ParsedSecretRef]] = []
58
+
59
+ for key, value in input_map.items():
60
+ if value is None:
61
+ continue
62
+ if not is_secret_ref(value):
63
+ output[key] = value
64
+ continue
65
+ try:
66
+ pending.append((key, parse_secret_ref(value)))
67
+ except ValueError:
68
+ if strict:
69
+ raise
70
+ output[key] = value
71
+
72
+ if not pending:
73
+ return output
74
+
75
+ results = await asyncio.gather(
76
+ *(_resolve_one(ref, providers) for _, ref in pending), return_exceptions=True
77
+ )
78
+
79
+ errors: List[ResolutionFailure] = []
80
+ for (key, ref), result in zip(pending, results):
81
+ if isinstance(result, BaseException):
82
+ errors.append(ResolutionFailure(key=key, ref=ref.raw, message=str(result)))
83
+ else:
84
+ output[key] = result
85
+
86
+ if errors:
87
+ raise SecRefsResolutionError(errors)
88
+
89
+ return output
90
+
91
+
92
+ async def expand_environ(providers: ProviderRegistry, *, strict: bool = True) -> List[str]:
93
+ """Expands sec:// values found in os.environ, mutating it in place."""
94
+ resolved = await expand_key_value_map(dict(os.environ), providers, strict=strict)
95
+ changed_keys: List[str] = []
96
+ for key, value in resolved.items():
97
+ if os.environ.get(key) != value:
98
+ os.environ[key] = value
99
+ changed_keys.append(key)
100
+ return changed_keys
101
+
102
+
103
+ async def check_references(
104
+ input_map: Mapping[str, Optional[str]], providers: ProviderRegistry
105
+ ) -> List[CheckResult]:
106
+ """
107
+ Dry-run validation: resolves every sec:// reference in `input_map` but
108
+ reports only ok/failure - secret values are never returned or logged.
109
+ """
110
+ results: List[CheckResult] = []
111
+ parsed_entries: List[Tuple[str, ParsedSecretRef]] = []
112
+
113
+ for key, value in input_map.items():
114
+ if not isinstance(value, str) or not is_secret_ref(value):
115
+ continue
116
+ try:
117
+ parsed_entries.append((key, parse_secret_ref(value)))
118
+ except ValueError as exc:
119
+ results.append(
120
+ CheckResult(key=key, ref=value, provider="unknown", ok=False, message=str(exc))
121
+ )
122
+
123
+ outcomes = await asyncio.gather(
124
+ *(_resolve_one(ref, providers) for _, ref in parsed_entries), return_exceptions=True
125
+ )
126
+
127
+ for (key, ref), outcome in zip(parsed_entries, outcomes):
128
+ results.append(
129
+ CheckResult(
130
+ key=key,
131
+ ref=ref.raw,
132
+ provider=ref.provider,
133
+ ok=not isinstance(outcome, BaseException),
134
+ message=str(outcome) if isinstance(outcome, BaseException) else None,
135
+ )
136
+ )
137
+
138
+ return results
secrefs/ttl_cache.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ A cache that expires, used by every provider that fetches over the network.
3
+
4
+ The default TTL is **zero** - every read re-fetches. That's deliberate and
5
+ it's the whole point of the product: a `sec://` reference is a stable name
6
+ for a value that changes underneath it. A consumer holding the reference is
7
+ supposed to see a rotated secret without being redeployed, and a cache with
8
+ no expiry silently breaks exactly that. Before this existed, a long-running
9
+ process fetched a secret once and held the pre-rotation value until restart.
10
+
11
+ A non-zero TTL is a real tradeoff, not a mistake: every expansion is a
12
+ network round trip, so a busy caller may want to trade a bounded window of
13
+ staleness for latency and API-rate-limit headroom. `ttl_ms=30_000` means "a
14
+ rotation reaches me within 30 seconds" - usually fine, and it should be a
15
+ decision someone made rather than a default they inherited.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Awaitable, Callable, Dict, Generic, Optional, TypeVar
24
+
25
+ T = TypeVar("T")
26
+
27
+
28
+ def _monotonic_ms() -> float:
29
+ """Monotonic rather than wall clock: a TTL is a duration, and an NTP
30
+ step or a DST change shouldn't stretch or collapse one."""
31
+ return time.monotonic() * 1000
32
+
33
+
34
+ @dataclass
35
+ class _Entry(Generic[T]):
36
+ value: T
37
+ stored_at: float
38
+
39
+
40
+ class TtlCache(Generic[T]):
41
+ def __init__(
42
+ self,
43
+ ttl_ms: float = 0.0,
44
+ now: Optional[Callable[[], float]] = None,
45
+ ) -> None:
46
+ """`ttl_ms` is how long an entry stays fresh; 0 (the default)
47
+ disables caching entirely - every `fetch` call goes to the source.
48
+ `now` is injected in tests so expiry doesn't require real waiting."""
49
+ self._ttl_ms = ttl_ms
50
+ self._now = now or _monotonic_ms
51
+ # Settled values, only ever populated when a TTL is configured.
52
+ self._entries: Dict[str, _Entry[T]] = {}
53
+ # Requests currently in flight, tracked separately from `_entries`
54
+ # because coalescing and caching are different things: sharing an
55
+ # unsettled request holds no value past the moment it resolves, so
56
+ # it stays correct even with caching fully disabled.
57
+ self._in_flight: Dict[str, "asyncio.Future[T]"] = {}
58
+
59
+ async def fetch(self, key: str, load: Callable[[], Awaitable[T]]) -> T:
60
+ """
61
+ Returns the cached value for `key` if it's still fresh, otherwise
62
+ awaits `load()` and caches that. In-flight requests are shared, so N
63
+ concurrent expansions of the same reference make one request rather
64
+ than N even when the TTL is zero - that's request coalescing, not
65
+ caching, and it doesn't hold a value past its use.
66
+
67
+ A failed load is dropped rather than remembered, so a transient
68
+ failure doesn't become a sticky one.
69
+ """
70
+ # Always join an in-flight request, whatever the TTL.
71
+ pending = self._in_flight.get(key)
72
+ if pending is None:
73
+ entry = self._entries.get(key)
74
+ if (
75
+ entry is not None
76
+ and self._ttl_ms > 0
77
+ and self._now() - entry.stored_at < self._ttl_ms
78
+ ):
79
+ return entry.value
80
+
81
+ pending = asyncio.ensure_future(load())
82
+ self._in_flight[key] = pending
83
+ # Cleared by the request itself rather than in a `finally` below,
84
+ # so the entry disappears exactly when the request settles even
85
+ # if the caller that started it was cancelled first.
86
+ pending.add_done_callback(lambda _task: self._in_flight.pop(key, None))
87
+
88
+ try:
89
+ # Shielded: one caller giving up (a cancelled `expand_string`,
90
+ # say) must not cancel the request every other caller joined.
91
+ value = await asyncio.shield(pending)
92
+ except Exception:
93
+ # Never remember a failure - a transient outage shouldn't become
94
+ # a sticky one for the length of the TTL.
95
+ self._entries.pop(key, None)
96
+ raise
97
+
98
+ if self._ttl_ms > 0:
99
+ # The settled value, not the future it came from: a retained
100
+ # entry can outlive the event loop that produced it, and an
101
+ # awaitable can't be awaited from a different one.
102
+ self._entries[key] = _Entry(value=value, stored_at=self._now())
103
+ return value
104
+
105
+ def clear(self) -> None:
106
+ """Drops everything retained - used when a credential changes
107
+ underneath the cache and anything fetched with the old one is
108
+ suspect."""
109
+ self._entries.clear()
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: secrefs
3
+ Version: 0.2.0
4
+ Summary: BYOV secret reference engine - expand sec:// references in memory at runtime
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: SecRefs
8
+ Author-email: hello@secrefs.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: bitwarden
18
+ Requires-Dist: bitwarden-sdk (>=2.1,<3.0) ; extra == "bitwarden"
19
+ Requires-Dist: boto3 (>=1.34,<2.0)
20
+ Requires-Dist: hvac (>=2.1,<3.0)
21
+ Project-URL: Homepage, https://secrefs.com
22
+ Project-URL: Repository, https://github.com/secrefs/secrefs
23
+ Description-Content-Type: text/markdown
24
+
25
+ # secrefs (Python)
26
+
27
+ Python SDK/CLI parity with `@secrefs/node`. See the repo root README for the
28
+ full `sec://` reference spec and provider list.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install secrefs
34
+ # Bitwarden Secrets Manager support is an extra - see "Providers" below:
35
+ pip install 'secrefs[bitwarden]'
36
+ # or, in this monorepo:
37
+ poetry install
38
+ ```
39
+
40
+ ## Library usage
41
+
42
+ ```python
43
+ import asyncio
44
+ import os
45
+ from secrefs import sec_refs
46
+
47
+ async def main():
48
+ await sec_refs.init() # expands sec:// values in os.environ, in place
49
+ print(os.environ["DB_PASSWORD"])
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ ## Two ways to expand, and when each is wrong
55
+
56
+ A `sec://` reference is a **stable name for a value that changes**. Which of
57
+ these you use decides whether that actually holds:
58
+
59
+ **At use (recommended).** Expand where the secret is consumed. Every call
60
+ re-fetches, so rotating the value at the source reaches this consumer with
61
+ no redeploy:
62
+
63
+ ```python
64
+ async def call_vendor_api():
65
+ key = await sec_refs.expand_string("sec://aws/hackerone#api_key")
66
+ ...
67
+ ```
68
+
69
+ **At load (`secrefs-py run`).** Expands once at startup and bakes plain
70
+ strings into the child's environment. Convenient, and the right choice for
71
+ short-lived processes like a CI job — but an environment variable is a
72
+ static string, so **a long-running process keeps the pre-rotation value
73
+ until it restarts**.
74
+
75
+ Network-backed providers re-fetch on every expansion by default. Concurrent
76
+ expansions of the same reference still share one request, and `cache_ttl_ms`
77
+ trades a bounded window of staleness for fewer round trips if you need it:
78
+
79
+ ```python
80
+ AWSSecretsManagerProvider(cache_ttl_ms=30_000) # rotation lands within 30s
81
+ ```
82
+
83
+ `LocalProvider` re-reads its JSON file per fetch for the same reason, so
84
+ editing `.secrefs.local.json` mid-session takes effect; `cache_file=True`
85
+ restores the old behavior.
86
+
87
+ ## Providers
88
+
89
+ | Alias | Backend | Ambient auth |
90
+ |---|---|---|
91
+ | `aws` | AWS Secrets Manager | boto3's default credential chain |
92
+ | `vault` | HashiCorp Vault (KV v1/v2) | `VAULT_ADDR` / `VAULT_TOKEN` |
93
+ | `bitwarden` | Bitwarden Secrets Manager | `BWS_ACCESS_TOKEN` / `BWS_ORGANIZATION_ID` |
94
+ | `local` | gitignored JSON file | n/a - development only |
95
+
96
+ `bitwarden` needs the `secrefs[bitwarden]` extra: Bitwarden secrets are
97
+ end-to-end encrypted, so only Bitwarden's own SDK can decrypt them, and it
98
+ ships as prebuilt native wheels for a fixed set of platforms. It's an extra
99
+ rather than a hard dependency so a platform it doesn't build for can still
100
+ install SecRefs.
101
+
102
+ ## Control-plane-sourced credentials
103
+
104
+ `AWSSecretsManagerProvider` and `BitwardenProvider` can source credentials
105
+ per request from a running control plane instead of the ambient environment
106
+ (see `docs/control-plane-design.md`), so each fetch is authenticated,
107
+ RBAC-checked and audited on the control plane's side:
108
+
109
+ ```python
110
+ from secrefs import AWSSecretsManagerProvider, ControlPlaneCredentialSource
111
+
112
+ AWSSecretsManagerProvider(
113
+ control_plane=ControlPlaneCredentialSource(
114
+ base_url=os.environ["SECREFS_CONTROL_PLANE_URL"],
115
+ token=os.environ["SECREFS_CONTROL_PLANE_TOKEN"],
116
+ alias="aws-prod",
117
+ )
118
+ )
119
+ ```
120
+
121
+ ## CLI usage
122
+
123
+ ```bash
124
+ secrefs-py run -- python app.py
125
+ secrefs-py check
126
+ ```
127
+
@@ -0,0 +1,18 @@
1
+ secrefs/__init__.py,sha256=PRo1iW0AUziMChVjaLvrLmTxaXTVdGhn_XcmoxWJQag,3458
2
+ secrefs/cli.py,sha256=1mVzX900pUUnuteDO5xmqnCSIbGBXq5gTIIMepcQXdg,4286
3
+ secrefs/control_plane_client.py,sha256=LShXrxOIjx7Hf_yXh_jyKtpKFC42pvcqdeJRSsOFXaM,8209
4
+ secrefs/envfile.py,sha256=2JeZfxOkS1wkbZHumzTQVNprp3N43RgdIyBoRTlaPMI,2992
5
+ secrefs/parser.py,sha256=5U7hrjXDdn9_zKmgD3BHlsFdEZgyBHzvDsmq4fK__VU,2190
6
+ secrefs/providers/__init__.py,sha256=CckreWD7Uge_g34-X19PkCB4CwE_hNsMLDM2MTpbou8,446
7
+ secrefs/providers/aws.py,sha256=Rv2BA959YCIKxWCpBxxJAbVBdm-s3nLoOqXQwUYjPm8,7444
8
+ secrefs/providers/base.py,sha256=3bxDXQO-CXLnOAw3kaRA6mRdtEMQBQkEcX9c5mJSIIA,2784
9
+ secrefs/providers/bitwarden.py,sha256=6v4nHedtgtxhHKpbb8A4hvBGoVqoU5RjdBQk5zYjEJk,12531
10
+ secrefs/providers/local.py,sha256=CL-Iih0PmK4zQvoj6halK-jXi28BPmwtddCT8ijT9Yg,3022
11
+ secrefs/providers/vault.py,sha256=fzie33D_wQQhRCqbwFJ0fWF7BuEvxC6Z1SPCVMmWjB4,4051
12
+ secrefs/resolver.py,sha256=_hURsbWCcnxVBZz-PNrBYnpXyxrFwkxeR6_z9uMBPn4,4453
13
+ secrefs/ttl_cache.py,sha256=o1_JLkUr7Ayc4f-14JM7Fe2ZUgm8hcChUttauvoPcbc,4621
14
+ secrefs-0.2.0.dist-info/METADATA,sha256=n1NjRbij0YuDA-L1AGGgrNTFMlf6mS51JbYYwHwZFQU,4125
15
+ secrefs-0.2.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
16
+ secrefs-0.2.0.dist-info/entry_points.txt,sha256=lkHYgN1oW8WFP7O1cLaszl3QUI-UhZzHN8fPN-hAbXg,47
17
+ secrefs-0.2.0.dist-info/licenses/LICENSE,sha256=YxxxY7FGq0yNW4kXF-EjJCam-lq1XndIn8GS17fDQrk,1072
18
+ secrefs-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ secrefs-py=secrefs.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armistice Group
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.