inbound-library/inbound_library/vault.py
tegwick b46355d6b0 Read the IMAP credential from OpenBao via AppRole on every run.
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
2026-10-05 13:13:51 +02:00

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).