feat(sp25): secrets.get_secret() env-var-first lookup

This commit is contained in:
Nora
2026-06-24 15:29:52 -06:00
parent 9fda46b891
commit b2d88d13d3
2 changed files with 158 additions and 12 deletions
+58 -12
View File
@@ -14,11 +14,26 @@ Setup (one-time, by the operator):
Verification:
security find-generic-password -s cyclone -a sftp.gainwell.password -w
SP25: the lookup now resolves the secret in three tiers, in this order:
1. Plain env var named exactly ``<name>`` — highest priority.
Lets a Linux server or Docker container pass the MFT password
via ``CYCLONE_SFTP_PASSWORD=...`` without touching the Keychain.
Trailing/leading whitespace (including the ``\\n`` that .env
files often leave at EOF) is stripped; an empty value is
treated as absent.
2. macOS Keychain via ``keyring`` — unchanged from SP9. Kept as a
fallback so a macOS workstation that prefers
``security add-generic-password`` works without env-var exports.
3. ``None`` — caller decides what to do (``SftpClient._connect``
raises a precise ``RuntimeError`` on real-mode auth).
"""
from __future__ import annotations
import logging
import os
from typing import Optional
log = logging.getLogger(__name__)
@@ -37,23 +52,44 @@ except ImportError:
def get_secret(name: str) -> Optional[str]:
"""Fetch a secret from macOS Keychain by name.
"""Fetch a secret by name. Three-tier lookup: env var, Keychain, None.
Args:
name: The Keychain account (e.g. "sftp.gainwell.password").
name: The secret name. Matches both the env-var name and the
Keychain account (e.g. ``"sftp.gainwell.password"`` /
``CYCLONE_SFTP_PASSWORD`` would NOT match — the env var
name is the bare secret name, not the
``CYCLONE_<UPPER_SNAKE_CASE>`` form). Operators who want
the env-var shortcut set the var named exactly ``<name>``.
For the Gainwell MFT password, the spec uses
``CYCLONE_SFTP_PASSWORD`` as the env var and the
``sftp.gainwell.password`` Keychain account as the
fallback. If we want them to match by name (one source of
truth), we map via the ``_ENV_NAME_FOR`` table at the
bottom of this module.
Returns:
The secret string, or None if the entry is missing or keyring
is not installed.
The secret string (stripped), or ``None`` if no tier produced
a value.
"""
if not _HAS_KEYRING:
log.warning("keyring not installed; get_secret(%r) returning None", name)
return None
try:
return keyring.get_password(SERVICE_NAME, name)
except Exception as exc: # noqa: BLE001 (Keychain can raise anything)
log.warning("Keychain get_secret(%r) failed: %s", name, exc)
return None
env_name = _ENV_NAME_FOR.get(name, name)
raw = os.environ.get(env_name)
if raw is not None:
stripped = raw.strip()
if stripped:
log.debug("Secret %r resolved from env var %r", name, env_name)
return stripped
# Empty env var — treat as absent and fall through.
if _HAS_KEYRING:
try:
value = keyring.get_password(SERVICE_NAME, name)
if value is not None:
return value
except Exception as exc: # noqa: BLE001 (Keychain can raise anything)
log.warning("Keychain get_secret(%r) failed: %s", name, exc)
return None
def set_secret(name: str, value: str) -> bool:
@@ -77,3 +113,13 @@ def has_keyring() -> bool:
"""True if the ``keyring`` library is importable (regardless of whether
the Keychain entry actually exists)."""
return _HAS_KEYRING
# Mapping from Keychain account name (used in ``SftpBlock.auth``) to
# the operator-facing env var. Keeping this table here means callers
# don't have to remember the difference between
# ``sftp.gainwell.password`` (Keychain account) and
# ``CYCLONE_SFTP_PASSWORD`` (env var).
_ENV_NAME_FOR: dict[str, str] = {
"sftp.gainwell.password": "CYCLONE_SFTP_PASSWORD",
}