feat(sp26): secrets.get_secret() _FILE-tier lookup for Docker secrets
This commit is contained in:
@@ -15,18 +15,27 @@ 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:
|
||||
SP25 + SP26: the lookup now resolves the secret in four tiers, in
|
||||
this order:
|
||||
|
||||
1. Plain env var named exactly ``<name>`` — highest priority.
|
||||
1. ``<env_name>_FILE`` env var set — highest priority. Reads the
|
||||
file at that path, strips whitespace, returns the contents.
|
||||
This is the standard Docker-secrets pattern: mount a file at
|
||||
``/run/secrets/<name>`` and point the env var at it. An
|
||||
operator who explicitly sets ``_FILE`` is making a positive
|
||||
statement about where the secret lives, so a missing file
|
||||
surfaces as a ``RuntimeError`` rather than silently falling
|
||||
through. Empty string is treated as unset.
|
||||
2. Plain env var named exactly ``<env_name>`` — second 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
|
||||
via ``CYCLONE_SFTP_PASSWORD=...`` without touching the Keychain
|
||||
or a file mount. Trailing/leading whitespace (including the
|
||||
``\\n`` that .env files often leave at EOF) is stripped; an
|
||||
empty value is treated as absent.
|
||||
3. macOS Keychain via ``keyring`` — third priority. 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``
|
||||
4. ``None`` — caller decides what to do (``SftpClient._connect``
|
||||
raises a precise ``RuntimeError`` on real-mode auth).
|
||||
"""
|
||||
|
||||
@@ -34,6 +43,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -51,29 +61,52 @@ except ImportError:
|
||||
_HAS_KEYRING = False
|
||||
|
||||
|
||||
def _env_var_for(name: str) -> str:
|
||||
"""Resolve the operator-facing env-var name for a secret.
|
||||
|
||||
Returns the entry from ``_ENV_NAME_FOR`` if present, otherwise
|
||||
returns ``name`` verbatim.
|
||||
"""
|
||||
return _ENV_NAME_FOR.get(name, name)
|
||||
|
||||
|
||||
def get_secret(name: str) -> Optional[str]:
|
||||
"""Fetch a secret by name. Three-tier lookup: env var, Keychain, None.
|
||||
"""Fetch a secret by name. Four-tier lookup: _FILE, env var, Keychain, None.
|
||||
|
||||
Args:
|
||||
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.
|
||||
name: The secret name. Matches the Keychain account name
|
||||
(e.g. ``"sftp.gainwell.password"``). The operator-facing
|
||||
env-var form is mapped via the ``_ENV_NAME_FOR`` table at
|
||||
the bottom of this module.
|
||||
|
||||
Returns:
|
||||
The secret string (stripped), or ``None`` if no tier produced
|
||||
a value.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if ``<env_name>_FILE`` is set but the file at
|
||||
that path is missing or unreadable. The operator made a
|
||||
positive statement about where the secret lives; silent
|
||||
fall-through would mask a misconfiguration.
|
||||
"""
|
||||
env_name = _ENV_NAME_FOR.get(name, name)
|
||||
env_name = _env_var_for(name)
|
||||
file_env = env_name + "_FILE"
|
||||
file_path_raw = os.environ.get(file_env)
|
||||
if file_path_raw:
|
||||
# An empty string is treated as unset (matches the SP25 rule
|
||||
# for plain env vars).
|
||||
stripped_path = file_path_raw.strip()
|
||||
if stripped_path:
|
||||
try:
|
||||
value = Path(stripped_path).read_text().strip()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"failed to read {file_env}={stripped_path}: {exc}"
|
||||
) from exc
|
||||
if value:
|
||||
log.debug("Secret %r resolved from file %r", name, stripped_path)
|
||||
return value
|
||||
|
||||
raw = os.environ.get(env_name)
|
||||
if raw is not None:
|
||||
stripped = raw.strip()
|
||||
|
||||
Reference in New Issue
Block a user