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:
parent
2d6d49aef2
commit
b46355d6b0
8 changed files with 233 additions and 30 deletions
17
AGENTS.md
17
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
65
inbound_library/vault.py
Normal 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).
|
||||
|
|
@ -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
91
tests/test_vault.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue