0940dc6972
Follow-up to the incident where an unwritable /data made Steward silently mint a fresh ephemeral secret key on every boot, orphaning all encrypted secrets — only discovered when an Ansible run failed. Make both failure modes loud and visible. - config._resolve_secret_key: if a new key must be generated but can't be persisted (no STEWARD_SECRET_KEY, unwritable /data), raise RuntimeError and refuse to start, with an actionable fix (set STEWARD_SECRET_KEY, or make /data writable by uid 1000). Also raises a clear error if an existing key file can't be read. First-run on a writable volume still generates + persists normally. - core.settings: detect secrets stored as ciphertext that won't decrypt with the current key (scan_undecryptable_secrets at startup; _is_undecryptable helper). Cached in memory; set_setting discards a key on a fresh write so the banner clears without a restart. - app.py: run the scan after migrate_plaintext_secrets, log a warning, and inject undecryptable_secrets into the template context. - base.html: admin banner naming which secrets to re-enter, each linked to its settings tab. - compose.deploy.yml: document the uid-1000 /data write requirement and the STEWARD_SECRET_KEY option for multi-node Swarm. - Tests: secret-key resolver (reuse existing file, generate when writable, raise when unpersistable) and the undecryptable-secret detection helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
import textwrap
|
|
import pytest
|
|
from steward.config import load_bootstrap, _resolve_secret_key
|
|
|
|
|
|
def test_load_bootstrap_from_yaml(tmp_path):
|
|
cfg_file = tmp_path / "config.yaml"
|
|
cfg_file.write_text(textwrap.dedent("""\
|
|
database:
|
|
url: postgresql+asyncpg://user:pass@localhost/steward
|
|
secret_key: test-secret
|
|
"""))
|
|
cfg = load_bootstrap(cfg_file)
|
|
assert cfg["database_url"] == "postgresql+asyncpg://user:pass@localhost/steward"
|
|
assert cfg["secret_key"] == "test-secret"
|
|
|
|
|
|
def test_env_var_database_url_overrides_yaml(tmp_path, monkeypatch):
|
|
cfg_file = tmp_path / "config.yaml"
|
|
cfg_file.write_text("database:\n url: original\nsecret_key: s\n")
|
|
monkeypatch.setenv("STEWARD_DATABASE_URL", "overridden")
|
|
cfg = load_bootstrap(cfg_file)
|
|
assert cfg["database_url"] == "overridden"
|
|
|
|
|
|
def test_env_var_secret_key_overrides_yaml(tmp_path, monkeypatch):
|
|
cfg_file = tmp_path / "config.yaml"
|
|
cfg_file.write_text("database:\n url: x\nsecret_key: from-yaml\n")
|
|
monkeypatch.setenv("STEWARD_SECRET_KEY", "from-env")
|
|
cfg = load_bootstrap(cfg_file)
|
|
assert cfg["secret_key"] == "from-env"
|
|
|
|
|
|
def test_missing_database_url_raises(tmp_path):
|
|
cfg_file = tmp_path / "config.yaml"
|
|
cfg_file.write_text("secret_key: s\n")
|
|
with pytest.raises(ValueError, match="Database URL is required"):
|
|
load_bootstrap(cfg_file)
|
|
|
|
|
|
# ── secret key resolution ──────────────────────────────────────────────────────
|
|
|
|
def test_secret_key_existing_file_is_reused(tmp_path, monkeypatch):
|
|
key_file = tmp_path / "secret.key"
|
|
key_file.write_text("persisted-key\n")
|
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
|
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
|
assert _resolve_secret_key({}) == "persisted-key"
|
|
|
|
|
|
def test_secret_key_generated_and_persisted_when_writable(tmp_path, monkeypatch):
|
|
key_file = tmp_path / "sub" / "secret.key" # parent doesn't exist yet
|
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
|
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
|
key = _resolve_secret_key({})
|
|
assert key and key_file.read_text().strip() == key
|
|
|
|
|
|
def test_secret_key_unpersistable_raises_instead_of_ephemeral(tmp_path, monkeypatch):
|
|
# A file sits where a directory is needed, so mkdir/write fails → must raise
|
|
# (an ephemeral key would silently orphan every encrypted secret).
|
|
blocker = tmp_path / "blocker"
|
|
blocker.write_text("not a dir")
|
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", blocker / "secret.key")
|
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
|
with pytest.raises(RuntimeError, match="could not persist"):
|
|
_resolve_secret_key({})
|