From b46355d6b0e2a1119f40f733c4a635a0eb84468d Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 5 Oct 2026 13:13:51 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1170398@bnt-lap001 Assistant-Session: 622cd705-cb31-4322-ba08-dd42e44fa583 --- AGENTS.md | 17 ++-- config/library.json | 6 ++ inbound_library/__main__.py | 37 ++++---- inbound_library/intake.py | 25 ++++- inbound_library/vault.py | 65 +++++++++++++ ops/systemd/inbound-library.service | 5 +- tests/test_vault.py | 91 +++++++++++++++++++ .../ILIB-WP-0003-openbao-approle-imap.md | 17 +++- 8 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 inbound_library/vault.py create mode 100644 tests/test_vault.py diff --git a/AGENTS.md b/AGENTS.md index eb29458..bd88279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/config/library.json b/config/library.json index 194486f..baa40ce 100644 --- a/config/library.json +++ b/config/library.json @@ -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" } } diff --git a/inbound_library/__main__.py b/inbound_library/__main__.py index 2910fc8..a093a56 100644 --- a/inbound_library/__main__.py +++ b/inbound_library/__main__.py @@ -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'} - 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)} - except (OSError, ValueError) as exc: - report['intake'] = {'ok': False, 'status': 'error', 'error': str(exc)} - report['ok'] = False + try: + mailbox = mailbox or intake.mailbox_from_config(config) + if mailbox is None: + report['intake'] = {'ok': False, 'status': 'needs_credentials'} + else: + 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'] = 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': - mailbox = (intake.DirectoryMailbox(args.fixture) if args.fixture - else intake.mailbox_from_config(config)) + 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 diff --git a/inbound_library/intake.py b/inbound_library/intake.py index 2701a1e..2575b3b 100644 --- a/inbound_library/intake.py +++ b/inbound_library/intake.py @@ -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): diff --git a/inbound_library/vault.py b/inbound_library/vault.py new file mode 100644 index 0000000..7b89ffb --- /dev/null +++ b/inbound_library/vault.py @@ -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 /') + 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). diff --git a/ops/systemd/inbound-library.service b/ops/systemd/inbound-library.service index e0666e2..72f6036 100644 --- a/ops/systemd/inbound-library.service +++ b/ops/systemd/inbound-library.service @@ -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 diff --git a/tests/test_vault.py b/tests/test_vault.py new file mode 100644 index 0000000..c757ac5 --- /dev/null +++ b/tests/test_vault.py @@ -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() diff --git a/workplans/ILIB-WP-0003-openbao-approle-imap.md b/workplans/ILIB-WP-0003-openbao-approle-imap.md index b71f948..35e7473 100644 --- a/workplans/ILIB-WP-0003-openbao-approle-imap.md +++ b/workplans/ILIB-WP-0003-openbao-approle-imap.md @@ -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