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/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from .control_plane_client import (
|
|
5
|
+
ControlPlaneClient,
|
|
6
|
+
ControlPlaneCredentialSource,
|
|
7
|
+
ControlPlaneRequestError,
|
|
8
|
+
MintCredentialResponse,
|
|
9
|
+
MintedAWSCredentials,
|
|
10
|
+
MintedBitwardenCredentials,
|
|
11
|
+
)
|
|
12
|
+
from .parser import (
|
|
13
|
+
ParsedSecretRef,
|
|
14
|
+
SecRefParseError,
|
|
15
|
+
is_secret_ref,
|
|
16
|
+
parse_secret_ref,
|
|
17
|
+
try_parse_secret_ref,
|
|
18
|
+
)
|
|
19
|
+
from .providers.aws import AWSSecretsManagerProvider
|
|
20
|
+
from .providers.base import ProviderHealth, SecretFetchError, SecretFetchRequest, SecretProvider
|
|
21
|
+
from .providers.bitwarden import BitwardenProvider
|
|
22
|
+
from .providers.local import LocalProvider
|
|
23
|
+
from .providers.vault import VaultProvider
|
|
24
|
+
from .resolver import (
|
|
25
|
+
CheckResult,
|
|
26
|
+
ProviderRegistry,
|
|
27
|
+
ResolutionFailure,
|
|
28
|
+
SecRefsResolutionError,
|
|
29
|
+
check_references,
|
|
30
|
+
expand_environ,
|
|
31
|
+
expand_key_value_map,
|
|
32
|
+
)
|
|
33
|
+
from .ttl_cache import TtlCache
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_default_providers() -> Dict[str, SecretProvider]:
|
|
37
|
+
"""Builds the default provider registry: aws, vault, local, bitwarden."""
|
|
38
|
+
return {
|
|
39
|
+
"aws": AWSSecretsManagerProvider(),
|
|
40
|
+
"vault": VaultProvider(),
|
|
41
|
+
"local": LocalProvider(),
|
|
42
|
+
"bitwarden": BitwardenProvider(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SecRefs:
|
|
47
|
+
"""Mirrors the Node SDK's `secRefs.init()` / `expandEnv()` / `expandString()` API."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
providers: Optional[Dict[str, SecretProvider]] = None,
|
|
52
|
+
strict: bool = True,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.providers: Dict[str, SecretProvider] = providers or create_default_providers()
|
|
55
|
+
self.strict = strict
|
|
56
|
+
|
|
57
|
+
async def init(self) -> List[str]:
|
|
58
|
+
"""Expands sec:// values found in os.environ, mutating it in place."""
|
|
59
|
+
return await expand_environ(self.providers, strict=self.strict)
|
|
60
|
+
|
|
61
|
+
async def expand_env(self, env: Dict[str, Optional[str]]) -> Dict[str, str]:
|
|
62
|
+
"""Expands sec:// values in an arbitrary key/value map without touching os.environ."""
|
|
63
|
+
return await expand_key_value_map(env, self.providers, strict=self.strict)
|
|
64
|
+
|
|
65
|
+
async def expand_string(self, value: str) -> str:
|
|
66
|
+
"""Expands a single string if it's a sec:// reference; otherwise returns it unchanged."""
|
|
67
|
+
if not is_secret_ref(value):
|
|
68
|
+
return value
|
|
69
|
+
result = await expand_key_value_map({"__value__": value}, self.providers, strict=True)
|
|
70
|
+
return result["__value__"]
|
|
71
|
+
|
|
72
|
+
async def check(self, env: Optional[Dict[str, Optional[str]]] = None) -> List[CheckResult]:
|
|
73
|
+
"""Dry-run validation of every sec:// reference in `env` (defaults to os.environ)."""
|
|
74
|
+
return await check_references(env if env is not None else dict(os.environ), self.providers)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
sec_refs = SecRefs()
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"SecRefs",
|
|
81
|
+
"sec_refs",
|
|
82
|
+
"create_default_providers",
|
|
83
|
+
"ParsedSecretRef",
|
|
84
|
+
"SecRefParseError",
|
|
85
|
+
"is_secret_ref",
|
|
86
|
+
"parse_secret_ref",
|
|
87
|
+
"try_parse_secret_ref",
|
|
88
|
+
"AWSSecretsManagerProvider",
|
|
89
|
+
"VaultProvider",
|
|
90
|
+
"LocalProvider",
|
|
91
|
+
"BitwardenProvider",
|
|
92
|
+
"SecretProvider",
|
|
93
|
+
"SecretFetchRequest",
|
|
94
|
+
"SecretFetchError",
|
|
95
|
+
"ProviderHealth",
|
|
96
|
+
"TtlCache",
|
|
97
|
+
"ControlPlaneClient",
|
|
98
|
+
"ControlPlaneCredentialSource",
|
|
99
|
+
"ControlPlaneRequestError",
|
|
100
|
+
"MintCredentialResponse",
|
|
101
|
+
"MintedAWSCredentials",
|
|
102
|
+
"MintedBitwardenCredentials",
|
|
103
|
+
"SecRefsResolutionError",
|
|
104
|
+
"ResolutionFailure",
|
|
105
|
+
"CheckResult",
|
|
106
|
+
"ProviderRegistry",
|
|
107
|
+
"expand_environ",
|
|
108
|
+
"expand_key_value_map",
|
|
109
|
+
"check_references",
|
|
110
|
+
]
|
secrefs/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from . import SecRefs, SecRefsResolutionError
|
|
13
|
+
from .envfile import parse_env_file_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_dotenv(path: Path) -> None:
|
|
17
|
+
"""Loads a `.env` file into `os.environ`, correctly preserving
|
|
18
|
+
`sec://...#field` fragments (see `envfile.py`). Mirrors dotenv.config()'s
|
|
19
|
+
default precedence: never overrides a value already present in
|
|
20
|
+
`os.environ` (e.g. set explicitly by the shell/CI)."""
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return
|
|
23
|
+
parsed = parse_env_file_text(path.read_text(encoding="utf-8"))
|
|
24
|
+
for key, value in parsed.items():
|
|
25
|
+
os.environ.setdefault(key, value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="secrefs-py",
|
|
31
|
+
description="BYOV secret reference engine - expand sec:// references in memory at runtime",
|
|
32
|
+
)
|
|
33
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
34
|
+
|
|
35
|
+
run_parser = subparsers.add_parser(
|
|
36
|
+
"run", help="Resolve sec:// references and spawn a child process"
|
|
37
|
+
)
|
|
38
|
+
run_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
|
|
39
|
+
run_parser.add_argument(
|
|
40
|
+
"--no-env-file", action="store_true", help="skip loading a .env file"
|
|
41
|
+
)
|
|
42
|
+
run_parser.add_argument(
|
|
43
|
+
"command", nargs=argparse.REMAINDER, help="command to run, e.g. -- python app.py"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
check_parser = subparsers.add_parser(
|
|
47
|
+
"check", help="Validate sec:// references and provider reachability"
|
|
48
|
+
)
|
|
49
|
+
check_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
|
|
50
|
+
check_parser.add_argument(
|
|
51
|
+
"--no-env-file", action="store_true", help="skip loading a .env file"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
58
|
+
command: List[str] = [c for c in args.command if c != "--"]
|
|
59
|
+
if not command:
|
|
60
|
+
print(
|
|
61
|
+
"secrefs-py run: no command given. Usage: secrefs-py run -- <command> [args...]",
|
|
62
|
+
file=sys.stderr,
|
|
63
|
+
)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
if not args.no_env_file:
|
|
67
|
+
_load_dotenv(Path(args.env_file))
|
|
68
|
+
|
|
69
|
+
instance = SecRefs()
|
|
70
|
+
try:
|
|
71
|
+
changed = await instance.init()
|
|
72
|
+
if changed:
|
|
73
|
+
print(
|
|
74
|
+
f"secrefs-py: resolved {len(changed)} secret reference(s): {', '.join(changed)}",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
except SecRefsResolutionError as exc:
|
|
78
|
+
print("secrefs-py: failed to resolve one or more secret references:", file=sys.stderr)
|
|
79
|
+
print(str(exc), file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
process = subprocess.Popen(command, env=os.environ.copy())
|
|
83
|
+
|
|
84
|
+
forwarded_signals = [
|
|
85
|
+
s for s in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)) if s is not None
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def _forward(sig: int, _frame: object) -> None:
|
|
89
|
+
if process.poll() is None:
|
|
90
|
+
process.send_signal(sig)
|
|
91
|
+
|
|
92
|
+
original_handlers = {sig: signal.getsignal(sig) for sig in forwarded_signals}
|
|
93
|
+
for sig in forwarded_signals:
|
|
94
|
+
signal.signal(sig, _forward)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return process.wait()
|
|
98
|
+
finally:
|
|
99
|
+
for sig, handler in original_handlers.items():
|
|
100
|
+
signal.signal(sig, handler)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _check(args: argparse.Namespace) -> int:
|
|
104
|
+
if not args.no_env_file:
|
|
105
|
+
_load_dotenv(Path(args.env_file))
|
|
106
|
+
|
|
107
|
+
instance = SecRefs()
|
|
108
|
+
results = await instance.check()
|
|
109
|
+
|
|
110
|
+
if not results:
|
|
111
|
+
print("secrefs-py check: no sec:// references found in the environment.")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
ok_count = 0
|
|
115
|
+
for result in results:
|
|
116
|
+
icon = "✓" if result.ok else "✗"
|
|
117
|
+
print(f"{icon} {result.key} -> {result.ref}")
|
|
118
|
+
if result.ok:
|
|
119
|
+
ok_count += 1
|
|
120
|
+
else:
|
|
121
|
+
print(f" {result.message}")
|
|
122
|
+
|
|
123
|
+
print(f"\n{ok_count}/{len(results)} reference(s) resolved successfully.")
|
|
124
|
+
return 0 if ok_count == len(results) else 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
parser = _build_parser()
|
|
129
|
+
args = parser.parse_args()
|
|
130
|
+
|
|
131
|
+
if args.subcommand == "run":
|
|
132
|
+
exit_code = asyncio.run(_run(args))
|
|
133
|
+
else:
|
|
134
|
+
exit_code = asyncio.run(_check(args))
|
|
135
|
+
|
|
136
|
+
sys.exit(exit_code)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin HTTP client for a running control plane's credential-broker endpoint
|
|
3
|
+
(docs/control-plane-design.md §7). This is the piece §10 flagged as the
|
|
4
|
+
missing link: every provider that supports control-plane-sourced credentials
|
|
5
|
+
(AWSSecretsManagerProvider, BitwardenProvider - see their `control_plane`
|
|
6
|
+
constructor option) constructs one of these instead of only ever reading
|
|
7
|
+
ambient env vars.
|
|
8
|
+
|
|
9
|
+
Deliberately just an HTTP wrapper with no retry/backoff/circuit-breaking
|
|
10
|
+
logic - a mint failure surfaces as a normal raised exception, same as any
|
|
11
|
+
other provider fetch failure, and the caller's existing error handling
|
|
12
|
+
(resolver.py's asyncio.gather aggregation) already does the right thing
|
|
13
|
+
with it.
|
|
14
|
+
|
|
15
|
+
The default transport is `urllib` from the standard library, offloaded to a
|
|
16
|
+
thread with `asyncio.to_thread` exactly the way boto3 and hvac calls are.
|
|
17
|
+
One POST to one endpoint doesn't justify making every SecRefs install carry
|
|
18
|
+
an HTTP client dependency it otherwise has no use for; a caller who wants
|
|
19
|
+
their own (httpx, aiohttp, a test double) passes `transport`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union
|
|
30
|
+
|
|
31
|
+
MINT_ENDPOINT = "/v1/credentials/mint"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MintedAWSCredentials:
|
|
36
|
+
access_key_id: str
|
|
37
|
+
secret_access_key: str
|
|
38
|
+
session_token: str
|
|
39
|
+
expiration: str # ISO-8601 timestamp
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MintedBitwardenCredentials:
|
|
44
|
+
access_token: str
|
|
45
|
+
# Explicitly not a TTL promise - see
|
|
46
|
+
# apps/control-plane/src/providers/bitwarden.ts.
|
|
47
|
+
note: str = ""
|
|
48
|
+
organization_id: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MintedCredentials = Union[MintedAWSCredentials, MintedBitwardenCredentials]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class MintCredentialResponse:
|
|
56
|
+
provider: str
|
|
57
|
+
# None when the control plane names a provider this SDK doesn't know how
|
|
58
|
+
# to parse credentials for. Left to the calling provider to reject, so
|
|
59
|
+
# the error it raises is the same "returned a X credential, expected Y"
|
|
60
|
+
# one it raises for a known-but-wrong provider rather than a second,
|
|
61
|
+
# differently-worded failure mode.
|
|
62
|
+
credentials: Optional[MintedCredentials] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ControlPlaneCredentialSource:
|
|
67
|
+
"""What a provider's `control_plane` constructor option needs - shared
|
|
68
|
+
shape between AWSSecretsManagerProvider and BitwardenProvider (and any
|
|
69
|
+
future control-plane-aware provider)."""
|
|
70
|
+
|
|
71
|
+
base_url: str
|
|
72
|
+
"""Base URL of a running control plane, e.g. from $SECREFS_CONTROL_PLANE_URL."""
|
|
73
|
+
token: str
|
|
74
|
+
"""Bootstrap token or a verified OIDC token, e.g. from $SECREFS_CONTROL_PLANE_TOKEN."""
|
|
75
|
+
alias: str
|
|
76
|
+
"""Which VaultConnection alias this provider instance represents - this
|
|
77
|
+
is what the control plane's RBAC grants are scoped against, not the
|
|
78
|
+
`sec://` alias this provider happens to be registered under (though in
|
|
79
|
+
practice they're usually the same string)."""
|
|
80
|
+
client: Optional["ControlPlaneClient"] = None
|
|
81
|
+
"""Injected for testing - defaults to a real ControlPlaneClient."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# (url, headers, body) -> (status, body). A non-2xx status is a *response*,
|
|
85
|
+
# not an exception: the body carries the control plane's denial reason and
|
|
86
|
+
# the caller needs it verbatim.
|
|
87
|
+
ControlPlaneTransport = Callable[[str, Dict[str, str], bytes], Awaitable[Tuple[int, bytes]]]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _urllib_transport(url: str, headers: Dict[str, str], body: bytes) -> Tuple[int, bytes]:
|
|
91
|
+
def send() -> Tuple[int, bytes]:
|
|
92
|
+
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(request) as response: # noqa: S310 - caller-supplied https URL
|
|
95
|
+
return int(response.status), bytes(response.read())
|
|
96
|
+
except urllib.error.HTTPError as exc:
|
|
97
|
+
# urllib raises on 4xx/5xx; a denial is a well-formed answer
|
|
98
|
+
# here, so hand the status and body back rather than the error.
|
|
99
|
+
return int(exc.code), bytes(exc.read())
|
|
100
|
+
|
|
101
|
+
return await asyncio.to_thread(send)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ControlPlaneRequestError(Exception):
|
|
105
|
+
"""Raised for a well-formed error response from the control plane (401,
|
|
106
|
+
403, 502, ...) - `status` and the message come straight from its
|
|
107
|
+
`{"error": ...}` body, so a denial reason (e.g. 'no grant authorizes
|
|
108
|
+
path...') reaches the caller verbatim rather than as an opaque HTTP
|
|
109
|
+
failure."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, status: int, message: str) -> None:
|
|
112
|
+
self.status = status
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ControlPlaneClient:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
base_url: str,
|
|
120
|
+
token: str,
|
|
121
|
+
transport: Optional[ControlPlaneTransport] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._base_url = base_url.rstrip("/")
|
|
124
|
+
self._token = token
|
|
125
|
+
self._transport: ControlPlaneTransport = transport or _urllib_transport
|
|
126
|
+
|
|
127
|
+
async def mint_credential(self, alias: str, path: str) -> MintCredentialResponse:
|
|
128
|
+
"""Authenticates, authorizes, and resolves a credential for
|
|
129
|
+
`alias`/`path` - see the control plane's POST /v1/credentials/mint.
|
|
130
|
+
Raises ControlPlaneRequestError for any non-2xx response."""
|
|
131
|
+
url = f"{self._base_url}{MINT_ENDPOINT}"
|
|
132
|
+
headers = {
|
|
133
|
+
"content-type": "application/json",
|
|
134
|
+
"authorization": f"Bearer {self._token}",
|
|
135
|
+
}
|
|
136
|
+
body = json.dumps({"alias": alias, "path": path}).encode("utf-8")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
status, raw = await self._transport(url, headers, body)
|
|
140
|
+
except Exception as exc: # noqa: BLE001 - re-raised with the URL for context
|
|
141
|
+
raise ValueError(f"could not reach control plane at {self._base_url}: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
if not 200 <= status < 300:
|
|
144
|
+
raise ControlPlaneRequestError(
|
|
145
|
+
status,
|
|
146
|
+
_error_message(raw)
|
|
147
|
+
or f'control plane returned {status} for alias "{alias}" path "{path}"',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
payload = json.loads(raw)
|
|
152
|
+
except json.JSONDecodeError as exc:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"control plane returned a non-JSON success response for alias "
|
|
155
|
+
f'"{alias}" path "{path}": {exc}'
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
return _parse_mint_response(payload)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _error_message(raw: bytes) -> Optional[str]:
|
|
162
|
+
"""The `{"error": ...}` body an error response is supposed to carry.
|
|
163
|
+
None if it isn't there - a proxy or load balancer in front of the
|
|
164
|
+
control plane may return HTML or nothing at all."""
|
|
165
|
+
try:
|
|
166
|
+
payload = json.loads(raw)
|
|
167
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
168
|
+
return None
|
|
169
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), str):
|
|
170
|
+
return str(payload["error"])
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_mint_response(payload: Any) -> MintCredentialResponse:
|
|
175
|
+
if not isinstance(payload, dict):
|
|
176
|
+
raise ValueError("control plane returned a malformed mint response")
|
|
177
|
+
|
|
178
|
+
provider = str(payload.get("provider", ""))
|
|
179
|
+
raw = payload.get("credentials")
|
|
180
|
+
fields: Dict[str, Any] = raw if isinstance(raw, dict) else {}
|
|
181
|
+
|
|
182
|
+
# Wire format is camelCase - it's produced by the control plane's
|
|
183
|
+
# TypeScript (apps/control-plane/src/routes/credentials.ts).
|
|
184
|
+
if provider == "aws":
|
|
185
|
+
return MintCredentialResponse(
|
|
186
|
+
provider=provider,
|
|
187
|
+
credentials=MintedAWSCredentials(
|
|
188
|
+
access_key_id=str(fields.get("accessKeyId", "")),
|
|
189
|
+
secret_access_key=str(fields.get("secretAccessKey", "")),
|
|
190
|
+
session_token=str(fields.get("sessionToken", "")),
|
|
191
|
+
expiration=str(fields.get("expiration", "")),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if provider == "bitwarden":
|
|
196
|
+
organization_id = fields.get("organizationId")
|
|
197
|
+
return MintCredentialResponse(
|
|
198
|
+
provider=provider,
|
|
199
|
+
credentials=MintedBitwardenCredentials(
|
|
200
|
+
access_token=str(fields.get("accessToken", "")),
|
|
201
|
+
note=str(fields.get("note", "")),
|
|
202
|
+
organization_id=str(organization_id) if organization_id else None,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return MintCredentialResponse(provider=provider)
|
secrefs/envfile.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`.env` file parsing, kept at parity with the Node SDK's `envFile.ts`.
|
|
2
|
+
|
|
3
|
+
A naive line-based `.env` parser treats `#` as a start-of-comment marker
|
|
4
|
+
mid-value, which silently truncates the `#field` fragment off an unquoted
|
|
5
|
+
`sec://provider/path#field` reference - the exact format SecRefs itself
|
|
6
|
+
uses. This module strips inline comments the way `.env` conventionally
|
|
7
|
+
works (including on quoted values), then restores the untruncated value
|
|
8
|
+
for any unquoted `sec://` assignment, since there `#` is URI syntax, not a
|
|
9
|
+
comment marker.
|
|
10
|
+
|
|
11
|
+
Note: because of this, an unquoted `sec://` value can't have a trailing
|
|
12
|
+
inline comment on the same line - put comments on their own line instead.
|
|
13
|
+
Quoted values (`KEY="sec://...#field"`) are unaffected either way.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Dict
|
|
19
|
+
|
|
20
|
+
# Matches an *unquoted* `KEY=sec://...` assignment, capturing the rest of
|
|
21
|
+
# the line verbatim as the value.
|
|
22
|
+
_UNQUOTED_SEC_REF_LINE = re.compile(
|
|
23
|
+
r"^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(sec://\S.*)$"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _strip_inline_comment(value: str) -> str:
|
|
28
|
+
"""Truncates an *unquoted* value at its first `#`, mirroring conventional
|
|
29
|
+
`.env` comment handling (e.g. `dotenv`'s own default behavior)."""
|
|
30
|
+
idx = value.find("#")
|
|
31
|
+
if idx == -1:
|
|
32
|
+
return value.rstrip()
|
|
33
|
+
return value[:idx].rstrip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def recover_truncated_sec_refs(raw_text: str, parsed: Dict[str, str]) -> Dict[str, str]:
|
|
37
|
+
"""Restores the full, untruncated value for any line that assigns an
|
|
38
|
+
unquoted `sec://...` reference, undoing `_strip_inline_comment`'s
|
|
39
|
+
truncation for exactly the cases where `#` is part of the URI rather
|
|
40
|
+
than a comment marker."""
|
|
41
|
+
result = dict(parsed)
|
|
42
|
+
for line in re.split(r"\r?\n", raw_text):
|
|
43
|
+
match = _UNQUOTED_SEC_REF_LINE.match(line)
|
|
44
|
+
if not match:
|
|
45
|
+
continue
|
|
46
|
+
key, value = match.group(1), match.group(2)
|
|
47
|
+
result[key] = value.rstrip()
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_env_file_text(raw_text: str) -> Dict[str, str]:
|
|
52
|
+
"""Parses a `.env` file's contents, correctly preserving
|
|
53
|
+
`sec://...#field` fragments."""
|
|
54
|
+
parsed: Dict[str, str] = {}
|
|
55
|
+
|
|
56
|
+
for raw_line in raw_text.splitlines():
|
|
57
|
+
line = raw_line.strip()
|
|
58
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
key, _, value = line.partition("=")
|
|
62
|
+
key = key.strip()
|
|
63
|
+
value = value.strip()
|
|
64
|
+
if not key:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if value and value[0] in ("'", '"'):
|
|
68
|
+
# A quoted value ends at its matching closing quote - anything
|
|
69
|
+
# after that (typically a trailing comment) is discarded,
|
|
70
|
+
# whether or not the quotes span the whole rest of the line.
|
|
71
|
+
quote = value[0]
|
|
72
|
+
end = value.find(quote, 1)
|
|
73
|
+
value = value[1:end] if end != -1 else value[1:]
|
|
74
|
+
else:
|
|
75
|
+
value = _strip_inline_comment(value)
|
|
76
|
+
|
|
77
|
+
parsed[key] = value
|
|
78
|
+
|
|
79
|
+
return recover_truncated_sec_refs(raw_text, parsed)
|
secrefs/parser.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URI parser for SecRefs' `sec://` reference format:
|
|
3
|
+
|
|
4
|
+
sec://<provider-alias>/<secret-path-or-id>[#<json-field>]
|
|
5
|
+
|
|
6
|
+
sec://aws/prod/db#password
|
|
7
|
+
sec://vault/secret/data/stripe#key
|
|
8
|
+
sec://local/mock-db#password
|
|
9
|
+
|
|
10
|
+
Mirrors packages/node/src/parser.ts exactly (same regex, same semantics),
|
|
11
|
+
so a `sec://` string means the same thing regardless of which SDK reads it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
_SEC_REF_PATTERN = re.compile(r"^sec://([a-zA-Z0-9][a-zA-Z0-9_-]*)/([^\s#]+)(?:#([^\s#]+))?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecRefParseError(ValueError):
|
|
24
|
+
def __init__(self, raw: str, reason: str) -> None:
|
|
25
|
+
self.raw = raw
|
|
26
|
+
self.reason = reason
|
|
27
|
+
super().__init__(f'Invalid secret reference "{raw}": {reason}')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ParsedSecretRef:
|
|
32
|
+
raw: str
|
|
33
|
+
provider: str
|
|
34
|
+
path: str
|
|
35
|
+
field: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_secret_ref(value: object) -> bool:
|
|
39
|
+
"""True if `value` is a string that looks like a `sec://` reference at all."""
|
|
40
|
+
return isinstance(value, str) and value.startswith("sec://")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_secret_ref(raw: object) -> ParsedSecretRef:
|
|
44
|
+
"""Parses a `sec://` reference string, raising SecRefParseError on any malformed input."""
|
|
45
|
+
if not isinstance(raw, str):
|
|
46
|
+
raise SecRefParseError(str(raw), "reference must be a string")
|
|
47
|
+
|
|
48
|
+
trimmed = raw.strip()
|
|
49
|
+
if not trimmed.startswith("sec://"):
|
|
50
|
+
raise SecRefParseError(raw, 'must start with "sec://"')
|
|
51
|
+
|
|
52
|
+
match = _SEC_REF_PATTERN.match(trimmed)
|
|
53
|
+
if not match:
|
|
54
|
+
raise SecRefParseError(raw, "does not match sec://<provider>/<path>[#field] format")
|
|
55
|
+
|
|
56
|
+
provider, path, field = match.groups()
|
|
57
|
+
if not provider:
|
|
58
|
+
raise SecRefParseError(raw, "missing provider alias")
|
|
59
|
+
if not path:
|
|
60
|
+
raise SecRefParseError(raw, "missing secret path")
|
|
61
|
+
|
|
62
|
+
return ParsedSecretRef(raw=raw, provider=provider.lower(), path=path, field=field or None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def try_parse_secret_ref(raw: object) -> Optional[ParsedSecretRef]:
|
|
66
|
+
"""Best-effort parse that returns None instead of raising."""
|
|
67
|
+
try:
|
|
68
|
+
return parse_secret_ref(raw)
|
|
69
|
+
except SecRefParseError:
|
|
70
|
+
return None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .aws import AWSSecretsManagerProvider
|
|
2
|
+
from .base import ProviderHealth, SecretFetchError, SecretFetchRequest, SecretProvider
|
|
3
|
+
from .bitwarden import BitwardenProvider
|
|
4
|
+
from .local import LocalProvider
|
|
5
|
+
from .vault import VaultProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AWSSecretsManagerProvider",
|
|
9
|
+
"BitwardenProvider",
|
|
10
|
+
"LocalProvider",
|
|
11
|
+
"VaultProvider",
|
|
12
|
+
"ProviderHealth",
|
|
13
|
+
"SecretFetchError",
|
|
14
|
+
"SecretFetchRequest",
|
|
15
|
+
"SecretProvider",
|
|
16
|
+
]
|