baton-auth 0.1.3__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.
- baton_auth/__init__.py +45 -0
- baton_auth/__main__.py +7 -0
- baton_auth/broker.py +342 -0
- baton_auth/catalog.py +268 -0
- baton_auth/cli.py +586 -0
- baton_auth/crypto.py +297 -0
- baton_auth/integration.py +191 -0
- baton_auth/materialize.py +215 -0
- baton_auth/mcp_server.py +202 -0
- baton_auth/models.py +160 -0
- baton_auth/py.typed +0 -0
- baton_auth/redact.py +98 -0
- baton_auth/refresh.py +242 -0
- baton_auth/runner.py +182 -0
- baton_auth/vault.py +747 -0
- baton_auth-0.1.3.dist-info/METADATA +78 -0
- baton_auth-0.1.3.dist-info/RECORD +19 -0
- baton_auth-0.1.3.dist-info/WHEEL +4 -0
- baton_auth-0.1.3.dist-info/entry_points.txt +4 -0
baton_auth/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""baton: pass auth context between agents as leases, never as secrets."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .crypto import CryptoError, zeroize
|
|
6
|
+
from .models import (
|
|
7
|
+
BatonError,
|
|
8
|
+
Context,
|
|
9
|
+
Credential,
|
|
10
|
+
Lease,
|
|
11
|
+
LeaseError,
|
|
12
|
+
RefreshError,
|
|
13
|
+
UnknownContext,
|
|
14
|
+
UnknownCredential,
|
|
15
|
+
VaultLocked,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = version("baton-auth")
|
|
20
|
+
except PackageNotFoundError: # running from an unpackaged source tree
|
|
21
|
+
__version__ = "0+local"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BatonError",
|
|
25
|
+
"Context",
|
|
26
|
+
"Credential",
|
|
27
|
+
"CryptoError",
|
|
28
|
+
"Lease",
|
|
29
|
+
"LeaseError",
|
|
30
|
+
"RefreshError",
|
|
31
|
+
"UnknownContext",
|
|
32
|
+
"UnknownCredential",
|
|
33
|
+
"VaultLocked",
|
|
34
|
+
"Vault",
|
|
35
|
+
"zeroize",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name: str):
|
|
41
|
+
if name == "Vault":
|
|
42
|
+
from .vault import Vault
|
|
43
|
+
|
|
44
|
+
return Vault
|
|
45
|
+
raise AttributeError(name)
|
baton_auth/__main__.py
ADDED
baton_auth/broker.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Brokered HTTP: the agent asks for a URL, the broker attaches the credential.
|
|
2
|
+
|
|
3
|
+
This is the second half of the never-hand-over-the-secret rule. Egress for APIs
|
|
4
|
+
goes through here so the token is used by the tool layer, not pasted into a
|
|
5
|
+
prompt. Signing schemes live with the credential, not with the caller.
|
|
6
|
+
|
|
7
|
+
SigV4 uses SHA-256 because the AWS specification fixes it; everything else in
|
|
8
|
+
baton uses BLAKE3.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import ipaddress
|
|
17
|
+
import socket
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import quote, urlparse
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .models import BatonError, Credential, LeaseError, UnknownCredential
|
|
26
|
+
from .redact import Redactor
|
|
27
|
+
from .vault import Vault
|
|
28
|
+
|
|
29
|
+
MAX_BODY = 200_000
|
|
30
|
+
|
|
31
|
+
_SAFE_HEADERS = {
|
|
32
|
+
"content-type",
|
|
33
|
+
"content-length",
|
|
34
|
+
"date",
|
|
35
|
+
"etag",
|
|
36
|
+
"last-modified",
|
|
37
|
+
"location",
|
|
38
|
+
"retry-after",
|
|
39
|
+
"x-ratelimit-limit",
|
|
40
|
+
"x-ratelimit-remaining",
|
|
41
|
+
"x-ratelimit-reset",
|
|
42
|
+
"x-request-id",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Cloud metadata and internal link-local endpoints blocked by default
|
|
46
|
+
_METADATA_HOSTS = frozenset({
|
|
47
|
+
"169.254.169.254",
|
|
48
|
+
"metadata.google.internal",
|
|
49
|
+
"metadata.local",
|
|
50
|
+
"fd00:ec2::254",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_target_url(
|
|
55
|
+
url: str,
|
|
56
|
+
*,
|
|
57
|
+
allow_private: bool = False,
|
|
58
|
+
allow_loopback: bool = True,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""SSRF guard: reject non-http schemes, metadata services, and internal/private IPs."""
|
|
61
|
+
parsed = urlparse(url)
|
|
62
|
+
scheme = parsed.scheme.lower()
|
|
63
|
+
if scheme not in ("http", "https"):
|
|
64
|
+
raise BrokerError(f"unsupported URL scheme {scheme!r} (must be http or https)")
|
|
65
|
+
|
|
66
|
+
host = parsed.hostname
|
|
67
|
+
if not host:
|
|
68
|
+
raise BrokerError("missing host in URL")
|
|
69
|
+
|
|
70
|
+
host_lower = host.lower()
|
|
71
|
+
if host_lower in _METADATA_HOSTS:
|
|
72
|
+
raise BrokerError(f"SSRF protection: access to metadata endpoint {host!r} is blocked")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
addr_infos = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
76
|
+
except socket.gaierror as exc:
|
|
77
|
+
raise BrokerError(f"could not resolve host {host!r}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
for _, _, _, _, sockaddr in addr_infos:
|
|
80
|
+
ip_str = sockaddr[0]
|
|
81
|
+
try:
|
|
82
|
+
ip = ipaddress.ip_address(ip_str)
|
|
83
|
+
except ValueError:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if not allow_loopback and ip.is_loopback:
|
|
87
|
+
raise BrokerError(f"SSRF protection: loopback address {ip_str} is blocked")
|
|
88
|
+
|
|
89
|
+
if ip.is_link_local:
|
|
90
|
+
raise BrokerError(f"SSRF protection: link-local address {ip_str} is blocked")
|
|
91
|
+
|
|
92
|
+
if not allow_private and ip.is_private and not ip.is_loopback:
|
|
93
|
+
raise BrokerError(f"SSRF protection: private network address {ip_str} is blocked")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BrokerError(BatonError):
|
|
98
|
+
"""Request could not be attributed to a credential."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class HttpResponse:
|
|
103
|
+
url: str
|
|
104
|
+
status: int
|
|
105
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
106
|
+
body: str = ""
|
|
107
|
+
truncated: bool = False
|
|
108
|
+
service: str = ""
|
|
109
|
+
credential: str = ""
|
|
110
|
+
redactions: int = 0
|
|
111
|
+
|
|
112
|
+
def as_dict(self) -> dict[str, Any]:
|
|
113
|
+
return {
|
|
114
|
+
"url": self.url,
|
|
115
|
+
"status": self.status,
|
|
116
|
+
"headers": self.headers,
|
|
117
|
+
"body": self.body,
|
|
118
|
+
"truncated": self.truncated,
|
|
119
|
+
"service": self.service,
|
|
120
|
+
"credential": self.credential,
|
|
121
|
+
"redactions": self.redactions,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pick_credential(creds: list[Credential], service: str | None) -> Credential:
|
|
126
|
+
if service:
|
|
127
|
+
for c in creds:
|
|
128
|
+
if c.name == service or c.provider == service:
|
|
129
|
+
return c
|
|
130
|
+
raise UnknownCredential(f"no credential for service {service!r} in context")
|
|
131
|
+
if len(creds) == 1:
|
|
132
|
+
return creds[0]
|
|
133
|
+
raise BrokerError(
|
|
134
|
+
"context has several credentials; name one with service=" + str([c.name for c in creds])
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _pick_credential_name(vault: Vault, names: list[str], service: str | None) -> str:
|
|
139
|
+
"""Select using public metadata before decrypting or refreshing a credential."""
|
|
140
|
+
if service:
|
|
141
|
+
for name in names:
|
|
142
|
+
provider = vault.get_credential_provider(name)
|
|
143
|
+
if provider is None:
|
|
144
|
+
raise UnknownCredential(f"unknown credential {name!r} (see `baton cred list`)")
|
|
145
|
+
if name == service or provider == service:
|
|
146
|
+
return name
|
|
147
|
+
raise UnknownCredential(f"no credential for service {service!r} in context")
|
|
148
|
+
if len(names) == 1:
|
|
149
|
+
return names[0]
|
|
150
|
+
raise BrokerError(
|
|
151
|
+
"context has several credentials; name one with service=" + str(names)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sigv4(
|
|
156
|
+
cred: Credential, method: str, url: str, headers: dict[str, str], body: bytes
|
|
157
|
+
) -> dict[str, str]:
|
|
158
|
+
"""AWS Signature Version 4 for a single request."""
|
|
159
|
+
akid = cred.secret.get("access_key_id", "")
|
|
160
|
+
secret = cred.secret.get("secret_access_key", "")
|
|
161
|
+
if not akid or not secret:
|
|
162
|
+
raise BrokerError(f"credential {cred.name} lacks AWS key material")
|
|
163
|
+
region = cred.secret.get("region") or "us-east-1"
|
|
164
|
+
service = str(cred.auth.get("service") or "execute-api")
|
|
165
|
+
|
|
166
|
+
parsed = urlparse(url)
|
|
167
|
+
host = parsed.netloc
|
|
168
|
+
now = datetime.now(timezone.utc)
|
|
169
|
+
amzdate = now.strftime("%Y%m%dT%H%M%SZ")
|
|
170
|
+
datestamp = now.strftime("%Y%m%d")
|
|
171
|
+
|
|
172
|
+
signed = {k.lower(): v for k, v in headers.items()}
|
|
173
|
+
signed["host"] = host
|
|
174
|
+
signed["x-amz-date"] = amzdate
|
|
175
|
+
if cred.secret.get("session_token"):
|
|
176
|
+
signed["x-amz-security-token"] = cred.secret["session_token"]
|
|
177
|
+
|
|
178
|
+
canonical_uri = quote(parsed.path or "/", safe="/-_.~")
|
|
179
|
+
canonical_query = "&".join(
|
|
180
|
+
sorted(
|
|
181
|
+
quote(k, safe="-_.~") + "=" + quote(v, safe="-_.~")
|
|
182
|
+
for k, v in (httpx.QueryParams(parsed.query).multi_items() if parsed.query else [])
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
keys = sorted(signed)
|
|
186
|
+
canonical_headers = "".join(f"{k}:{signed[k].strip()}\n" for k in keys)
|
|
187
|
+
signed_headers = ";".join(keys)
|
|
188
|
+
payload_hash = hashlib.sha256(body).hexdigest()
|
|
189
|
+
|
|
190
|
+
canonical_request = "\n".join(
|
|
191
|
+
[method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash]
|
|
192
|
+
)
|
|
193
|
+
scope = f"{datestamp}/{region}/{service}/aws4_request"
|
|
194
|
+
string_to_sign = "\n".join(
|
|
195
|
+
[
|
|
196
|
+
"AWS4-HMAC-SHA256",
|
|
197
|
+
amzdate,
|
|
198
|
+
scope,
|
|
199
|
+
hashlib.sha256(canonical_request.encode()).hexdigest(),
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _hmac(key: bytes, msg: str) -> bytes:
|
|
204
|
+
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
|
205
|
+
|
|
206
|
+
k_date = _hmac(b"AWS4" + secret.encode(), datestamp)
|
|
207
|
+
k_region = _hmac(k_date, region)
|
|
208
|
+
k_service = _hmac(k_region, service)
|
|
209
|
+
k_signing = _hmac(k_service, "aws4_request")
|
|
210
|
+
signature = hmac.new(k_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
|
211
|
+
|
|
212
|
+
out = dict(headers)
|
|
213
|
+
for k in ("host", "x-amz-date", "x-amz-security-token"):
|
|
214
|
+
if k in signed and k not in {h.lower() for h in out}:
|
|
215
|
+
out[k] = signed[k]
|
|
216
|
+
out["Authorization"] = (
|
|
217
|
+
f"AWS4-HMAC-SHA256 Credential={akid}/{scope}, "
|
|
218
|
+
f"SignedHeaders={signed_headers}, Signature={signature}"
|
|
219
|
+
)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def attach_auth(
|
|
224
|
+
cred: Credential, method: str, url: str, headers: dict[str, str], body: bytes
|
|
225
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
226
|
+
"""Return (headers, query overrides) with the credential applied."""
|
|
227
|
+
scheme = str(cred.auth.get("scheme", "bearer"))
|
|
228
|
+
field_name = str(cred.auth.get("field", "token"))
|
|
229
|
+
secret = cred.secret.get(field_name, "")
|
|
230
|
+
out = dict(headers)
|
|
231
|
+
query: dict[str, str] = {}
|
|
232
|
+
|
|
233
|
+
if scheme == "sigv4":
|
|
234
|
+
return _sigv4(cred, method, url, out, body), query
|
|
235
|
+
if scheme == "bearer":
|
|
236
|
+
if not secret:
|
|
237
|
+
raise BrokerError(f"credential {cred.name} has no {field_name}")
|
|
238
|
+
out["Authorization"] = f"Bearer {secret}"
|
|
239
|
+
elif scheme == "header":
|
|
240
|
+
header_name = str(cred.auth.get("header", "x-api-key"))
|
|
241
|
+
if not secret:
|
|
242
|
+
raise BrokerError(f"credential {cred.name} has no {field_name}")
|
|
243
|
+
out[header_name] = secret
|
|
244
|
+
elif scheme == "basic":
|
|
245
|
+
user = cred.secret.get(str(cred.auth.get("user", "username")), "")
|
|
246
|
+
password = cred.secret.get(str(cred.auth.get("password", "password")), "")
|
|
247
|
+
out["Authorization"] = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
248
|
+
elif scheme == "query":
|
|
249
|
+
if not secret:
|
|
250
|
+
raise BrokerError(f"credential {cred.name} has no {field_name}")
|
|
251
|
+
query[str(cred.auth.get("param", "key"))] = secret
|
|
252
|
+
elif scheme == "none":
|
|
253
|
+
pass
|
|
254
|
+
else:
|
|
255
|
+
raise BrokerError(f"unsupported auth scheme {scheme!r}")
|
|
256
|
+
return out, query
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def request(
|
|
260
|
+
vault: Vault,
|
|
261
|
+
target: str,
|
|
262
|
+
method: str,
|
|
263
|
+
url: str,
|
|
264
|
+
*,
|
|
265
|
+
service: str | None = None,
|
|
266
|
+
headers: dict[str, str] | None = None,
|
|
267
|
+
body: bytes | None = None,
|
|
268
|
+
json_body: Any | None = None,
|
|
269
|
+
timeout: float = 30.0,
|
|
270
|
+
allow_private: bool = False,
|
|
271
|
+
allow_loopback: bool = True,
|
|
272
|
+
) -> HttpResponse:
|
|
273
|
+
validate_target_url(url, allow_private=allow_private, allow_loopback=allow_loopback)
|
|
274
|
+
lease = None
|
|
275
|
+
if target.startswith("btn_"):
|
|
276
|
+
lease = vault.resolve_lease(target)
|
|
277
|
+
if not lease.allows(service):
|
|
278
|
+
raise LeaseError(f"lease does not grant {service!r}")
|
|
279
|
+
context_name = lease.context
|
|
280
|
+
else:
|
|
281
|
+
context_name = target
|
|
282
|
+
|
|
283
|
+
ctx = vault.get_context(context_name)
|
|
284
|
+
if not ctx.credentials:
|
|
285
|
+
raise BrokerError(f"context {context_name} has no credentials")
|
|
286
|
+
cred_name = _pick_credential_name(vault, ctx.credentials, service)
|
|
287
|
+
cred = vault.get_credential(cred_name, auto_refresh=True)
|
|
288
|
+
|
|
289
|
+
payload = body if body is not None else b""
|
|
290
|
+
if json_body is not None:
|
|
291
|
+
import json as _json
|
|
292
|
+
|
|
293
|
+
payload = _json.dumps(json_body).encode()
|
|
294
|
+
headers = {**(headers or {}), "content-type": "application/json"}
|
|
295
|
+
|
|
296
|
+
public_url = url
|
|
297
|
+
final_headers, query = attach_auth(cred, method.upper(), url, dict(headers or {}), payload)
|
|
298
|
+
if query:
|
|
299
|
+
sep = "&" if "?" in url else "?"
|
|
300
|
+
url = url + sep + "&".join(f"{k}={quote(v, safe='')}" for k, v in query.items())
|
|
301
|
+
|
|
302
|
+
# A redirect destination has not passed the SSRF guard. Return the 3xx
|
|
303
|
+
# response so the caller can explicitly make a separately validated request.
|
|
304
|
+
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
|
|
305
|
+
resp = client.request(method.upper(), url, headers=final_headers, content=payload or None)
|
|
306
|
+
|
|
307
|
+
# Query auth percent-encodes values. Echoes in bodies or Location headers
|
|
308
|
+
# need to be scrubbed in both their original and wire representations.
|
|
309
|
+
secret_values = list(cred.secret.values())
|
|
310
|
+
redactor = Redactor([*secret_values, *(quote(value, safe="") for value in secret_values)])
|
|
311
|
+
raw = resp.text
|
|
312
|
+
truncated = len(raw) > MAX_BODY
|
|
313
|
+
clean, hits = redactor.redact_counted(raw[:MAX_BODY])
|
|
314
|
+
safe_headers = {
|
|
315
|
+
k: v for k, v in resp.headers.items() if k.lower() in _SAFE_HEADERS
|
|
316
|
+
}
|
|
317
|
+
clean_headers = {}
|
|
318
|
+
for k, v in safe_headers.items():
|
|
319
|
+
clean_headers[k], n = redactor.redact_counted(v)
|
|
320
|
+
hits += n
|
|
321
|
+
# Never return the URL after query authentication has added a secret.
|
|
322
|
+
safe_url, n = redactor.redact_counted(public_url)
|
|
323
|
+
hits += n
|
|
324
|
+
|
|
325
|
+
vault.audit(
|
|
326
|
+
lease.id if lease else None,
|
|
327
|
+
None,
|
|
328
|
+
"http",
|
|
329
|
+
f"{method.upper()} {urlparse(url).netloc}{urlparse(url).path}",
|
|
330
|
+
resp.is_success,
|
|
331
|
+
{"status": resp.status_code, "credential": cred.name},
|
|
332
|
+
)
|
|
333
|
+
return HttpResponse(
|
|
334
|
+
url=safe_url,
|
|
335
|
+
status=resp.status_code,
|
|
336
|
+
headers=clean_headers,
|
|
337
|
+
body=clean,
|
|
338
|
+
truncated=truncated,
|
|
339
|
+
service=service or cred.provider,
|
|
340
|
+
credential=cred.name,
|
|
341
|
+
redactions=hits,
|
|
342
|
+
)
|
baton_auth/catalog.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Provider presets: how a secret becomes a working CLI auth.
|
|
2
|
+
|
|
3
|
+
A preset is the whole domain knowledge of this tool. For each provider it fixes
|
|
4
|
+
(a) the secret fields, (b) the materialization steps that make a CLI accept
|
|
5
|
+
those fields in an isolated HOME, and (c) how the brokered HTTP layer attaches
|
|
6
|
+
the credential.
|
|
7
|
+
|
|
8
|
+
Templating: `{field}` is substituted from the secret payload, `{HOME}` from the
|
|
9
|
+
sandbox home. Anything else left unresolved is a hard error at plan time.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .models import BatonError, Credential
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PresetError(BatonError):
|
|
21
|
+
"""Invalid provider or missing required fields."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Preset:
|
|
26
|
+
provider: str
|
|
27
|
+
fields: tuple[str, ...] = ()
|
|
28
|
+
optional: tuple[str, ...] = ()
|
|
29
|
+
materialize: tuple[dict[str, Any], ...] = ()
|
|
30
|
+
auth: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
hint: str = ""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def all_fields(self) -> tuple[str, ...]:
|
|
35
|
+
return self.fields + self.optional
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_GH_HOSTS = "github.com:\n oauth_token: {token}\n git_protocol: https\n"
|
|
39
|
+
_GH_HOSTS_USER = " user: {user}\n"
|
|
40
|
+
|
|
41
|
+
PRESETS: dict[str, Preset] = {
|
|
42
|
+
"aws": Preset(
|
|
43
|
+
provider="aws",
|
|
44
|
+
fields=("access_key_id", "secret_access_key"),
|
|
45
|
+
optional=("session_token", "region"),
|
|
46
|
+
materialize=(
|
|
47
|
+
{
|
|
48
|
+
"kind": "env",
|
|
49
|
+
"map": {
|
|
50
|
+
"AWS_ACCESS_KEY_ID": "{access_key_id}",
|
|
51
|
+
"AWS_SECRET_ACCESS_KEY": "{secret_access_key}",
|
|
52
|
+
"AWS_SESSION_TOKEN": "{session_token}",
|
|
53
|
+
"AWS_REGION": "{region}",
|
|
54
|
+
"AWS_DEFAULT_REGION": "{region}",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"kind": "ini",
|
|
59
|
+
"path": ".aws/credentials",
|
|
60
|
+
"section": "default",
|
|
61
|
+
"map": {
|
|
62
|
+
"aws_access_key_id": "{access_key_id}",
|
|
63
|
+
"aws_secret_access_key": "{secret_access_key}",
|
|
64
|
+
"aws_session_token": "{session_token}",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
),
|
|
68
|
+
auth={"scheme": "sigv4", "service": "execute-api"},
|
|
69
|
+
hint="AWS access key (or STS session credentials)",
|
|
70
|
+
),
|
|
71
|
+
"gcp": Preset(
|
|
72
|
+
provider="gcp",
|
|
73
|
+
fields=("service_account_json",),
|
|
74
|
+
materialize=(
|
|
75
|
+
{
|
|
76
|
+
"kind": "file",
|
|
77
|
+
"path": ".config/gcloud/baton-sa.json",
|
|
78
|
+
"mode": "0600",
|
|
79
|
+
"template": "{service_account_json}",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"kind": "env",
|
|
83
|
+
"map": {
|
|
84
|
+
"GOOGLE_APPLICATION_CREDENTIALS": "{HOME}/.config/gcloud/baton-sa.json",
|
|
85
|
+
"CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "{HOME}/.config/gcloud/baton-sa.json",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
auth={"scheme": "bearer", "field": "access_token"},
|
|
90
|
+
hint="GCP service-account JSON blob",
|
|
91
|
+
),
|
|
92
|
+
"github": Preset(
|
|
93
|
+
provider="github",
|
|
94
|
+
fields=("token",),
|
|
95
|
+
optional=("user",),
|
|
96
|
+
materialize=(
|
|
97
|
+
{
|
|
98
|
+
"kind": "env",
|
|
99
|
+
"map": {"GH_TOKEN": "{token}", "GITHUB_TOKEN": "{token}"},
|
|
100
|
+
},
|
|
101
|
+
{"kind": "file", "path": ".config/gh/hosts.yml", "template": _GH_HOSTS},
|
|
102
|
+
{
|
|
103
|
+
"kind": "file",
|
|
104
|
+
"path": ".config/gh/hosts.yml",
|
|
105
|
+
"optional": True,
|
|
106
|
+
"template": _GH_HOSTS_USER,
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
auth={"scheme": "bearer", "field": "token"},
|
|
110
|
+
hint="GitHub PAT (ghp_/github_pat_) or fine-grained token",
|
|
111
|
+
),
|
|
112
|
+
"gitlab": Preset(
|
|
113
|
+
provider="gitlab",
|
|
114
|
+
fields=("token",),
|
|
115
|
+
materialize=(
|
|
116
|
+
{"kind": "env", "map": {"GITLAB_TOKEN": "{token}", "GLAB_TOKEN": "{token}"}},
|
|
117
|
+
),
|
|
118
|
+
auth={"scheme": "header", "header": "PRIVATE-TOKEN", "field": "token"},
|
|
119
|
+
hint="GitLab PAT (glpat-)",
|
|
120
|
+
),
|
|
121
|
+
"npm": Preset(
|
|
122
|
+
provider="npm",
|
|
123
|
+
fields=("token",),
|
|
124
|
+
materialize=(
|
|
125
|
+
{"kind": "env", "map": {"NPM_TOKEN": "{token}"}},
|
|
126
|
+
{
|
|
127
|
+
"kind": "file",
|
|
128
|
+
"path": ".npmrc",
|
|
129
|
+
"template": "//registry.npmjs.org/:_authToken={token}\n",
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
auth={"scheme": "bearer", "field": "token"},
|
|
133
|
+
hint="npm registry token",
|
|
134
|
+
),
|
|
135
|
+
"docker": Preset(
|
|
136
|
+
provider="docker",
|
|
137
|
+
fields=("username", "password"),
|
|
138
|
+
optional=("registry",),
|
|
139
|
+
materialize=(
|
|
140
|
+
{
|
|
141
|
+
"kind": "docker_auth",
|
|
142
|
+
"path": ".docker/config.json",
|
|
143
|
+
"registry": "{registry}",
|
|
144
|
+
"username": "{username}",
|
|
145
|
+
"password": "{password}",
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
auth={"scheme": "basic", "user": "username", "password": "password"},
|
|
149
|
+
hint="Container registry credentials",
|
|
150
|
+
),
|
|
151
|
+
"kube": Preset(
|
|
152
|
+
provider="kube",
|
|
153
|
+
fields=("kubeconfig",),
|
|
154
|
+
materialize=(
|
|
155
|
+
{
|
|
156
|
+
"kind": "file",
|
|
157
|
+
"path": ".kube/config",
|
|
158
|
+
"mode": "0600",
|
|
159
|
+
"template": "{kubeconfig}",
|
|
160
|
+
},
|
|
161
|
+
{"kind": "env", "map": {"KUBECONFIG": "{HOME}/.kube/config"}},
|
|
162
|
+
),
|
|
163
|
+
auth={"scheme": "bearer", "field": "token"},
|
|
164
|
+
hint="Full kubeconfig YAML",
|
|
165
|
+
),
|
|
166
|
+
"ssh": Preset(
|
|
167
|
+
provider="ssh",
|
|
168
|
+
fields=("private_key",),
|
|
169
|
+
optional=("public_key", "known_hosts", "key_name"),
|
|
170
|
+
materialize=(
|
|
171
|
+
{
|
|
172
|
+
"kind": "file",
|
|
173
|
+
"path": ".ssh/{key_name}",
|
|
174
|
+
"mode": "0600",
|
|
175
|
+
"template": "{private_key}\n",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"kind": "file",
|
|
179
|
+
"path": ".ssh/{key_name}.pub",
|
|
180
|
+
"mode": "0644",
|
|
181
|
+
"optional": True,
|
|
182
|
+
"template": "{public_key}\n",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"kind": "file",
|
|
186
|
+
"path": ".ssh/known_hosts",
|
|
187
|
+
"mode": "0644",
|
|
188
|
+
"optional": True,
|
|
189
|
+
"template": "{known_hosts}",
|
|
190
|
+
},
|
|
191
|
+
),
|
|
192
|
+
hint="SSH private key material",
|
|
193
|
+
),
|
|
194
|
+
"generic": Preset(
|
|
195
|
+
provider="generic",
|
|
196
|
+
fields=("token",),
|
|
197
|
+
materialize=(),
|
|
198
|
+
auth={"scheme": "bearer", "field": "token"},
|
|
199
|
+
hint="Bare token; add env mapping with --env NAME={token}",
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def preset(provider: str) -> Preset:
|
|
205
|
+
if not provider.strip():
|
|
206
|
+
raise PresetError("provider name cannot be empty")
|
|
207
|
+
return PRESETS.get(provider) or Preset(
|
|
208
|
+
provider=provider,
|
|
209
|
+
auth={"scheme": "bearer", "field": "token"},
|
|
210
|
+
hint="Custom provider; bearer token by default",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_credential(
|
|
215
|
+
name: str,
|
|
216
|
+
provider: str,
|
|
217
|
+
values: dict[str, str],
|
|
218
|
+
*,
|
|
219
|
+
expires_at: float | None = None,
|
|
220
|
+
notes: str = "",
|
|
221
|
+
env_map: dict[str, str] | None = None,
|
|
222
|
+
refresh: dict[str, Any] | None = None,
|
|
223
|
+
auth: dict[str, Any] | None = None,
|
|
224
|
+
) -> Credential:
|
|
225
|
+
"""Validate field coverage and assemble a Credential from a preset."""
|
|
226
|
+
p = preset(provider)
|
|
227
|
+
if provider == "ssh" and "key_name" not in values:
|
|
228
|
+
values = {**values, "key_name": "id_baton"}
|
|
229
|
+
auth_spec = dict(auth) if auth is not None else dict(p.auth)
|
|
230
|
+
if provider in PRESETS and provider != "generic":
|
|
231
|
+
required = list(p.fields)
|
|
232
|
+
elif auth_spec.get("scheme") == "basic":
|
|
233
|
+
required = ["username", "password"]
|
|
234
|
+
elif auth_spec.get("scheme") == "none":
|
|
235
|
+
required = []
|
|
236
|
+
else:
|
|
237
|
+
required = [str(auth_spec.get("field", "token"))]
|
|
238
|
+
if auth is not None:
|
|
239
|
+
if auth_spec.get("scheme") == "basic":
|
|
240
|
+
required.extend(("username", "password"))
|
|
241
|
+
elif auth_spec.get("scheme") in {"bearer", "header", "query"}:
|
|
242
|
+
required.append(str(auth_spec.get("field", "token")))
|
|
243
|
+
missing = [f for f in dict.fromkeys(required) if not values.get(f)]
|
|
244
|
+
if missing:
|
|
245
|
+
raise PresetError(f"{provider}: missing required field(s): {', '.join(missing)}")
|
|
246
|
+
if not any(values.values()):
|
|
247
|
+
raise PresetError(f"{provider}: provide at least one secret field")
|
|
248
|
+
allowed = set(p.all_fields) | {"refresh_token", "client_id", "client_secret"}
|
|
249
|
+
unknown = [k for k in values if k not in allowed]
|
|
250
|
+
if unknown and provider in PRESETS and provider != "generic":
|
|
251
|
+
raise PresetError(f"{provider}: unknown field(s): {', '.join(unknown)}")
|
|
252
|
+
|
|
253
|
+
steps: list[dict[str, Any]] = [dict(s) for s in p.materialize]
|
|
254
|
+
if env_map:
|
|
255
|
+
steps.append({"kind": "env", "map": dict(env_map)})
|
|
256
|
+
|
|
257
|
+
# Incomplete optional steps are dropped at plan time; prune empty templates here.
|
|
258
|
+
return Credential(
|
|
259
|
+
name=name,
|
|
260
|
+
provider=provider,
|
|
261
|
+
secret={k: v for k, v in values.items() if v},
|
|
262
|
+
materialize=steps,
|
|
263
|
+
auth=auth_spec,
|
|
264
|
+
refresh=dict(refresh or {}),
|
|
265
|
+
expires_at=expires_at,
|
|
266
|
+
notes=notes,
|
|
267
|
+
meta={"preset": provider},
|
|
268
|
+
)
|