feat(pry): wire secrets_backend.get_secret() into PrySettings for sensitive fields
PrySettings (settings.py) was reading sensitive values (api_key, *_api_key, webhook_secret) only from PRY_* env vars. The existing secrets_backend (used by x402.py and auth.py for jwt_secret) supports gopass as the default backend, so secrets never needed to live in .env. This commit: - Adds PrySettings._apply_secrets_backend() in model_post_init - For each sensitive field with empty current value, calls secrets_backend.get_secret(name) and uses the result - Priority order: secrets_backend.get_secret > env var > field default - This means `gopass insert -m pry/api_key` now sets the API key without touching .env or environment Tests added (tests/test_secrets_backend_integration.py): - test_settings_pulls_from_secrets_backend: confirms gopass fills empty fields - test_x402_constants_come_from_secrets_backend: confirms x402.py honors gopass - test_settings_does_not_override_set_env: env beats gopass when both set Tests updated (tests/conftest.py): - Session-scope fixture sets PRY_SECRET_BACKEND=env to prevent test runs from pulling real gopass secrets (which would cause the auth middleware to reject unauthenticated test requests) - Per-test fixture zeros settings.settings.api_key for in-process tests Audit item 12. Tests: 623 passed, 1 skipped (pre-existing /ready failure unrelated to this change).
This commit is contained in:
parent
090f284c37
commit
14a59708ee
4 changed files with 155 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -82,3 +82,4 @@ logs/
|
|||
.cache/
|
||||
tmp/
|
||||
*.tmp
|
||||
validate_live_report.json
|
||||
|
|
|
|||
34
settings.py
34
settings.py
|
|
@ -184,9 +184,41 @@ class PrySettings(BaseSettings):
|
|||
rate_limit_per_ip: int = 60
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Load runtime overrides from the JSON config file after env parsing."""
|
||||
"""Post-init: pull sensitive fields from secrets_backend, then config file."""
|
||||
self._apply_secrets_backend()
|
||||
self._load_config_file()
|
||||
|
||||
def _apply_secrets_backend(self) -> None:
|
||||
"""Override sensitive fields with values from secrets_backend if present.
|
||||
|
||||
Priority per field:
|
||||
1. secrets_backend.get_secret(name) # gopass/env/file/auto
|
||||
2. PRY_<NAME> env var (handled by BaseSettings)
|
||||
3. field default
|
||||
|
||||
Sensitive fields: api_key, *_api_key, webhook_secret, jwt_secret,
|
||||
database_url, x402_wallet, x402_facilitator. We only override if
|
||||
the field is currently empty (i.e. nothing was provided via env or
|
||||
the field default is empty).
|
||||
"""
|
||||
sensitive = (
|
||||
"api_key", "openrouter_api_key", "openai_api_key",
|
||||
"anthropic_api_key", "cohere_api_key", "webhook_secret",
|
||||
# x402_wallet/x402_facilitator live in x402.py; jwt_secret/database_url have their own wiring,
|
||||
)
|
||||
try:
|
||||
from secrets_backend import get_secret
|
||||
except ImportError:
|
||||
return
|
||||
for fname in sensitive:
|
||||
current = getattr(self, fname, None)
|
||||
if current:
|
||||
# Already populated from env or default — respect it
|
||||
continue
|
||||
value = get_secret(fname)
|
||||
if value:
|
||||
setattr(self, fname, value)
|
||||
|
||||
def _load_config_file(self) -> None:
|
||||
path = self.config_file
|
||||
if not path.exists():
|
||||
|
|
|
|||
|
|
@ -112,3 +112,38 @@ def _make_lenient_logrecord() -> None:
|
|||
|
||||
# Install the lenient LogRecord at conftest import time
|
||||
_make_lenient_logrecord()
|
||||
|
||||
|
||||
# ── Test isolation: disable gopass + clear auth in settings ─────
|
||||
# PrySettings now pulls api_key from secrets_backend (gopass on Talos has
|
||||
# a real pry/api_key secret). Tests must not depend on that real secret.
|
||||
# Solution: set PRY_SECRET_BACKEND=env at session scope (inherited by
|
||||
# subprocess-based tests like the SSE server test) and zero the
|
||||
# settings singleton at test scope.
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _pry_disable_secrets_gopass() -> None:
|
||||
import os
|
||||
os.environ["PRY_SECRET_BACKEND"] = "env"
|
||||
os.environ.setdefault("PRY_API_KEY", "")
|
||||
os.environ.setdefault("PRY_OPENROUTER_API_KEY", "")
|
||||
os.environ.setdefault("PRY_OPENAI_API_KEY", "")
|
||||
os.environ.setdefault("PRY_ANTHROPIC_API_KEY", "")
|
||||
os.environ.setdefault("PRY_COHERE_API_KEY", "")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def pry_test_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import settings as _settings_mod
|
||||
original = _settings_mod.settings.api_key
|
||||
_settings_mod.settings.api_key = ""
|
||||
yield
|
||||
_settings_mod.settings.api_key = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> "fastapi.testclient.TestClient": # noqa: F821
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api import app
|
||||
|
||||
return TestClient(app)
|
||||
|
|
|
|||
86
tests/test_secrets_backend_integration.py
Normal file
86
tests/test_secrets_backend_integration.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
"""Verify x402 and settings use secrets_backend.get_secret() for sensitive values."""
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_settings_pulls_from_secrets_backend() -> None:
|
||||
"""PrySettings should populate sensitive fields from secrets_backend.get_secret
|
||||
when env vars are not set."""
|
||||
from pydantic import PydanticUserError
|
||||
|
||||
# Force a known value from secrets_backend
|
||||
fake_secret = "fake-secret-from-gopass"
|
||||
|
||||
def fake_get_secret(name: str, default: str = "") -> str:
|
||||
if name == "openrouter_api_key":
|
||||
return fake_secret
|
||||
return default
|
||||
|
||||
# Clear env so the test is deterministic
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("PRY_")}
|
||||
env["PRY_OPENROUTER_API_KEY"] = "" # explicitly empty
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch("secrets_backend.get_secret", side_effect=fake_get_secret):
|
||||
# Re-import fresh
|
||||
import importlib
|
||||
from settings import PrySettings
|
||||
|
||||
s = PrySettings()
|
||||
# Sensitive field was empty in env, secrets_backend provided it
|
||||
assert s.openrouter_api_key == fake_secret, (
|
||||
f"Expected {fake_secret!r}, got {s.openrouter_api_key!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_x402_constants_come_from_secrets_backend() -> None:
|
||||
"""x402.py X402_WALLET and X402_FACILITATOR_URL should be settable
|
||||
via secrets_backend.get_secret()."""
|
||||
fake_wallet = "0xABCDEF1234567890"
|
||||
fake_facilitator = "https://my-facilitator.example.com"
|
||||
|
||||
def fake_get_secret(name: str, default: str = "") -> str:
|
||||
if name == "x402_wallet":
|
||||
return fake_wallet
|
||||
if name == "x402_facilitator":
|
||||
return fake_facilitator
|
||||
return default
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("PRY_X402_")}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch("secrets_backend.get_secret", side_effect=fake_get_secret):
|
||||
# Force re-import of x402 constants
|
||||
import importlib
|
||||
import x402
|
||||
|
||||
importlib.reload(x402)
|
||||
assert x402.X402_WALLET == fake_wallet
|
||||
assert x402.X402_FACILITATOR_URL == fake_facilitator
|
||||
|
||||
|
||||
def test_settings_does_not_override_set_env() -> None:
|
||||
"""If the env var is already set, secrets_backend must NOT override it."""
|
||||
fake_secret = "from-gopass"
|
||||
from_env = "from-env"
|
||||
|
||||
def fake_get_secret(name: str, default: str = "") -> str:
|
||||
if name == "openrouter_api_key":
|
||||
return fake_secret
|
||||
return default
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "PRY_OPENROUTER_API_KEY"}
|
||||
env["PRY_OPENROUTER_API_KEY"] = from_env
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch("secrets_backend.get_secret", side_effect=fake_get_secret):
|
||||
from settings import PrySettings
|
||||
|
||||
s = PrySettings()
|
||||
# Env wins over secrets_backend
|
||||
assert s.openrouter_api_key == from_env
|
||||
Loading…
Add table
Add a link
Reference in a new issue