ILIB-WP-0003-T04: stdlib AppRole login, single KV v2 read and token revoke-self; no env-file cache; credential_unavailable keeps matching and notices running. Five fake-OpenBao tests; 19 total pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1170398@bnt-lap001 Assistant-Session: 622cd705-cb31-4322-ba08-dd42e44fa583
65 lines
2.8 KiB
Python
65 lines
2.8 KiB
Python
"""Unattended OpenBao credential fetch through an AppRole.
|
|
|
|
Each run logs in with the delivered role_id/secret_id, reads one KV v2 entry
|
|
and revokes the token. Values stay in memory for the run. They are never logged,
|
|
printed or written to disk.
|
|
"""
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
class CredentialUnavailable(Exception):
|
|
"""The credential could not be obtained. The message never contains a secret."""
|
|
|
|
|
|
def _call(addr, method, path, token=None, body=None, timeout=15):
|
|
request = urllib.request.Request(
|
|
addr.rstrip('/') + '/v1/' + path.lstrip('/'), method=method,
|
|
data=None if body is None else json.dumps(body).encode(),
|
|
headers={'Content-Type': 'application/json', **({'X-Vault-Token': token} if token else {})})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
raw = response.read()
|
|
return json.loads(raw) if raw else {}
|
|
except urllib.error.HTTPError as exc:
|
|
# Status only. The response body could echo request data.
|
|
raise CredentialUnavailable(f'OpenBao {method} {path.split("/")[0]}/… returned {exc.code}') from None
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
raise CredentialUnavailable(f'OpenBao unreachable at {addr}: {type(exc).__name__}') from None
|
|
|
|
|
|
def kv_v2_data_path(kv_path):
|
|
mount, _, rest = kv_path.partition('/')
|
|
if not rest:
|
|
raise ValueError('kv_path must be <mount>/<path>')
|
|
return f'{mount}/data/{rest}'
|
|
|
|
|
|
def fetch(spec, call=_call):
|
|
"""Return the KV fields as a dict, read through AppRole login."""
|
|
addr = os.environ.get('BAO_ADDR') or spec.get('addr', 'http://127.0.0.1:18200')
|
|
approle = Path(os.path.expanduser(spec.get('approle_dir', '~/.local/inbound-library/approle')))
|
|
try:
|
|
role_id = (approle / 'role_id').read_text().strip()
|
|
secret_id = (approle / 'secret_id').read_text().strip()
|
|
except OSError:
|
|
raise CredentialUnavailable(f'AppRole credentials missing in {approle}') from None
|
|
login = call(addr, 'POST', f"auth/{spec.get('approle_mount', 'approle')}/login",
|
|
body={'role_id': role_id, 'secret_id': secret_id})
|
|
token = (login.get('auth') or {}).get('client_token')
|
|
if not token:
|
|
raise CredentialUnavailable('AppRole login returned no token')
|
|
try:
|
|
read = call(addr, 'GET', kv_v2_data_path(spec['kv_path']), token=token)
|
|
data = ((read.get('data') or {}).get('data')) or {}
|
|
if not data:
|
|
raise CredentialUnavailable(f"no data at {spec['kv_path']}")
|
|
return data
|
|
finally:
|
|
try:
|
|
call(addr, 'POST', 'auth/token/revoke-self', token=token)
|
|
except CredentialUnavailable:
|
|
pass # The token expires on its own (bounded TTL and number of uses).
|