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
This commit is contained in:
tegwick 2026-10-05 13:13:51 +02:00
parent 2d6d49aef2
commit b46355d6b0
8 changed files with 233 additions and 30 deletions

View file

@ -109,12 +109,17 @@ Python 3.11+ standard library only; no install step.
| `python3 -m inbound_library health` | last scan, silence, quarantine, pending notices |
Retained mail lives in the gitignored `data/` (see `docs/data-contracts.md`).
IMAP credentials come only from `INBOUND_LIBRARY_IMAP_USER` and
`INBOUND_LIBRARY_IMAP_PASSWORD`, which the timer reads from
`~/.config/inbound-library/imap.env` (mode 0600). The source of truth is OpenBao
`platform/workloads/inbound-library/imap`. Coding agents do not read that file
or the KV entry. Verify a live scan through `systemctl --user start
inbound-library.service` and its journal.
The IMAP credential is read from OpenBao `platform/workloads/inbound-library/imap`
on every run (`inbound_library/vault.py`). The run logs in with the AppRole
`inbound-library` using `~/.local/inbound-library/approle/{role_id,secret_id}`
(delivered by ops-mason), reads the entry once, then revokes the token. Values
are held in memory only. OpenBao is reached through the bridge forward
`openbao-ui-railiance01` (`127.0.0.1:18200`); if it is down, `run-due` reports
`credential_unavailable` and matching and notices still run.
`INBOUND_LIBRARY_IMAP_USER`/`_PASSWORD` remain as an explicit override for
one-off runs only. Coding agents do not read the AppRole files or the KV entry.
Verify a live scan through `systemctl --user start inbound-library.service` and
its journal.
## Session Protocol

View file

@ -9,5 +9,11 @@
"hub": {
"api_base": "http://127.0.0.1:8000",
"from_agent": "inbound-library"
},
"credential": {
"addr": "http://127.0.0.1:18200",
"approle_mount": "approle",
"approle_dir": "~/.local/inbound-library/approle",
"kv_path": "platform/workloads/inbound-library/imap"
}
}

View file

@ -5,7 +5,7 @@ import json
import sys
from pathlib import Path
from . import intake, notify, store
from . import intake, notify, store, vault
from . import registry as reg
DEFAULT_CONFIG = Path('config') / 'library.json'
@ -34,17 +34,18 @@ def run_due(root, config, mailbox=None, post=notify.post_message):
if problems:
return {'ok': False, 'status': 'invalid_registry', 'problems': problems}
report = {'ok': True, 'status': 'checked'}
try:
mailbox = mailbox or intake.mailbox_from_config(config)
if mailbox is None:
report['intake'] = {'ok': False, 'status': 'needs_credentials'}
report['ok'] = False
else:
try:
retained = intake.scan(root, mailbox, registry)
report['intake'] = {'ok': True, 'retained': len(retained)}
report['intake'] = {'ok': True,
'retained': len(intake.scan(root, mailbox, registry))}
except vault.CredentialUnavailable as exc:
report['intake'] = {'ok': False, 'status': 'credential_unavailable', 'error': str(exc)}
except (OSError, ValueError) as exc:
report['intake'] = {'ok': False, 'status': 'error', 'error': str(exc)}
report['ok'] = False
report['ok'] = report['intake']['ok']
report['requeued'] = len(intake.requeue_quarantine(root, registry))
report['notices'] = len(notify.match(root, registry))
hub = config.get('hub', {})
@ -90,11 +91,15 @@ def main(argv=None):
print(f"Registry and store valid; {len(store.records(root, 'deliveries'))} deliveries")
return 0
if args.command == 'scan':
try:
mailbox = (intake.DirectoryMailbox(args.fixture) if args.fixture
else intake.mailbox_from_config(config))
except vault.CredentialUnavailable as exc:
print(f'credential_unavailable: {exc}', file=sys.stderr)
return 2
if mailbox is None:
print('needs_credentials: set INBOUND_LIBRARY_IMAP_USER and '
'INBOUND_LIBRARY_IMAP_PASSWORD', file=sys.stderr)
print('needs_credentials: configure "credential" in config/library.json '
'or set INBOUND_LIBRARY_IMAP_USER/PASSWORD', file=sys.stderr)
return 2
_print({'retained': intake.scan(root, mailbox)})
return 0

View file

@ -68,16 +68,31 @@ class DirectoryMailbox:
yield self.uidvalidity, uid, stamp, path.read_bytes()
def mailbox_from_config(config, environ=None):
"""Build the live mailbox. Credentials come only from the environment."""
def mailbox_from_config(config, environ=None, fetch=None):
"""Build the live mailbox, or None when no credential source is configured.
The environment variables are an explicit override (tests, one-off runs).
Otherwise the credential is read from OpenBao on every call through the
AppRole in config["credential"], and never cached.
"""
environ = os.environ if environ is None else environ
spec = config.get('mailbox') or {}
host, port = spec.get('host', 'imap.ionos.de'), spec.get('port', 993)
folder = spec.get('folder', 'INBOX')
user = environ.get(spec.get('user_env', 'INBOUND_LIBRARY_IMAP_USER'))
password = environ.get(spec.get('password_env', 'INBOUND_LIBRARY_IMAP_PASSWORD'))
if not user or not password:
if user and password:
return ImapMailbox(host, port, folder, user, password)
credential = config.get('credential')
if not credential:
return None
return ImapMailbox(spec.get('host', 'imap.ionos.de'), spec.get('port', 993),
spec.get('folder', 'INBOX'), user, password)
from . import vault
fields = (fetch or vault.fetch)(credential)
if not fields.get('IMAP_USERNAME') or not fields.get('IMAP_PASSWORD'):
raise vault.CredentialUnavailable('credential lacks IMAP_USERNAME or IMAP_PASSWORD')
return ImapMailbox(fields.get('IMAP_HOST', host), fields.get('IMAP_PORT', port),
fields.get('IMAP_FOLDER', folder), fields['IMAP_USERNAME'],
fields['IMAP_PASSWORD'])
def _text(message):

65
inbound_library/vault.py Normal file
View file

@ -0,0 +1,65 @@
"""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).

View file

@ -5,6 +5,7 @@ After=network-online.target
[Service]
Type=oneshot
WorkingDirectory=%h/inbound-library
# INBOUND_LIBRARY_IMAP_USER / INBOUND_LIBRARY_IMAP_PASSWORD, mode 0600, never committed.
EnvironmentFile=-%h/.config/inbound-library/imap.env
# The IMAP credential is read from OpenBao on every run through the inbound-library
# AppRole (config/library.json "credential"). Nothing is cached on disk. OpenBao is
# reached through the openbao-ui-railiance01 bridge forward on 127.0.0.1:18200.
ExecStart=/usr/bin/python3 -m inbound_library run-due

91
tests/test_vault.py Normal file
View file

@ -0,0 +1,91 @@
import json
import tempfile
import unittest
from pathlib import Path
from inbound_library import intake, vault
from inbound_library.__main__ import run_due
SECRET = 's3cret-imap-password'
class FakeBao:
def __init__(self, deny_read=False, down=False):
self.calls, self.deny_read, self.down = [], deny_read, down
def __call__(self, addr, method, path, token=None, body=None):
self.calls.append((method, path, token))
if self.down:
raise vault.CredentialUnavailable(f'OpenBao unreachable at {addr}: URLError')
if path == 'auth/approle/login':
assert body == {'role_id': 'role-1', 'secret_id': 'secret-1'}
return {'auth': {'client_token': 'tok-1'}}
if path == 'platform/data/workloads/inbound-library/imap':
if self.deny_read:
raise vault.CredentialUnavailable('OpenBao GET platform/… returned 403')
return {'data': {'data': {'IMAP_USERNAME': 'library@coulomb.social',
'IMAP_PASSWORD': SECRET, 'IMAP_HOST': 'imap.ionos.de',
'IMAP_PORT': '993', 'IMAP_FOLDER': 'INBOX'}}}
if path == 'auth/token/revoke-self':
return {}
raise AssertionError(path)
class Vault(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.approle = Path(self.tmp.name) / 'approle'
self.approle.mkdir()
(self.approle / 'role_id').write_text('role-1\n')
(self.approle / 'secret_id').write_text('secret-1\n')
self.spec = {'addr': 'http://bao', 'approle_dir': str(self.approle),
'kv_path': 'platform/workloads/inbound-library/imap'}
def tearDown(self):
self.tmp.cleanup()
def test_login_read_and_revoke(self):
bao = FakeBao()
fields = vault.fetch(self.spec, call=bao)
self.assertEqual(fields['IMAP_PASSWORD'], SECRET)
self.assertEqual([c[1] for c in bao.calls], [
'auth/approle/login', 'platform/data/workloads/inbound-library/imap',
'auth/token/revoke-self'])
def test_denied_read_still_revokes_and_hides_secret(self):
bao = FakeBao(deny_read=True)
with self.assertRaises(vault.CredentialUnavailable) as caught:
vault.fetch(self.spec, call=bao)
self.assertEqual(bao.calls[-1][1], 'auth/token/revoke-self')
self.assertNotIn('secret-1', str(caught.exception))
def test_missing_approle_files(self):
(self.approle / 'secret_id').unlink()
with self.assertRaises(vault.CredentialUnavailable):
vault.fetch(self.spec, call=FakeBao())
def test_mailbox_built_from_vault_fields(self):
box = intake.mailbox_from_config(
{'credential': self.spec}, environ={},
fetch=lambda spec: vault.fetch(spec, call=FakeBao()))
self.assertEqual((box.user, box.host, box.port, box.folder),
('library@coulomb.social', 'imap.ionos.de', 993, 'INBOX'))
def test_run_due_reports_unreachable_bao_without_secret_and_still_matches(self):
root = Path(self.tmp.name) / 'lib'
(root / 'registry').mkdir(parents=True)
(root / 'registry' / 'topics.json').write_text(json.dumps({'version': 1, 'topics': {}}))
original = vault.fetch
vault.fetch = lambda spec: original(spec, call=FakeBao(down=True))
try:
report = run_due(root, {'credential': self.spec}, post=lambda *a: None)
finally:
vault.fetch = original
self.assertFalse(report['ok'])
self.assertEqual(report['intake']['status'], 'credential_unavailable')
self.assertIn('notices', report)
self.assertNotIn(SECRET, json.dumps(report))
if __name__ == '__main__':
unittest.main()

View file

@ -100,7 +100,7 @@ Acceptance: the plan is `built`, and the evidence is in ops-mason
```task
id: ILIB-WP-0003-T04
status: todo
status: done
priority: high
state_hub_task_id: "9338e9cc-5539-5bd4-ba2f-c5fb550ffb4e"
```
@ -120,6 +120,21 @@ override for tests. When OpenBao is unreachable, `run-due` reports
Acceptance: the tests use a fake OpenBao (login, read, denied, unreachable).
No secret appears in any output.
Done 2026-10-05: `inbound_library/vault.py` is in place. On each run it logs in
with the AppRole, reads the KV v2 entry once, then revokes its own token. It
reads `credential` (addr, approle_dir, kv_path) from `config/library.json`.
`run-due` reports `credential_unavailable` and still matches and notifies. The
`EnvironmentFile` was removed from the unit. 5 fake-OpenBao tests: login, read,
revoke; a denied read still revokes and hides the secret; missing AppRole files;
the mailbox is built from the KV fields; unreachable OpenBao. 19 tests pass in
total.
Implications for the T01 plan:
- `token_num_uses` must be at least 2 (one read, one revoke-self). 4 leaves room
for a retry.
- `BAO_ADDR` is the bridge forward `127.0.0.1:18200` (`openbao-ui-railiance01`).
`bao.coulomb.social` does not serve the API publicly (404).
## Route the lane in ops-warden
```task