165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
"""macOS Keychain secret accessor for Cyclone.
|
|
|
|
SP9. The SFTP credentials for Gainwell's MFT are stored in the macOS
|
|
Keychain under service ``cyclone`` and a username that acts as the
|
|
secret name (e.g. ``sftp.gainwell.password``). This module fetches
|
|
them by name.
|
|
|
|
Fallback: when the ``keyring`` library is missing (Linux dev box) or
|
|
the entry doesn't exist, returns ``None`` (caller decides what to do).
|
|
A stub secret ``<stub-secret>`` is provided for the SP9 stub flow.
|
|
|
|
Setup (one-time, by the operator):
|
|
security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
|
|
|
|
Verification:
|
|
security find-generic-password -s cyclone -a sftp.gainwell.password -w
|
|
|
|
SP25 + SP26: the lookup now resolves the secret in four tiers, in
|
|
this order:
|
|
|
|
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
|
|
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.
|
|
4. ``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 pathlib import Path
|
|
from typing import Optional
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SERVICE_NAME = "cyclone"
|
|
STUB_SECRET = "<stub-secret>"
|
|
|
|
# Try to import keyring lazily — it's an optional dep so the rest of
|
|
# the codebase doesn't fail on Linux dev boxes without it.
|
|
try:
|
|
import keyring # type: ignore[import-untyped]
|
|
_HAS_KEYRING = True
|
|
except ImportError:
|
|
keyring = None # type: ignore[assignment]
|
|
_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. Four-tier lookup: _FILE, env var, Keychain, None.
|
|
|
|
Args:
|
|
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_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()
|
|
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:
|
|
"""Set a secret in macOS Keychain. Returns True on success.
|
|
|
|
Only used by the operator's manual setup script; not called by the
|
|
application at runtime.
|
|
"""
|
|
if not _HAS_KEYRING:
|
|
log.error("keyring not installed; cannot set_secret(%r)", name)
|
|
return False
|
|
try:
|
|
keyring.set_password(SERVICE_NAME, name, value)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
log.error("Keychain set_secret(%r) failed: %s", name, exc)
|
|
return False
|
|
|
|
|
|
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",
|
|
# SP40: Edifabric /v2/x12/validate API key. Operator supplies a
|
|
# paid tier key for production; the dev/CI path reads the free
|
|
# ``EdiNation Developer API`` provisional key from
|
|
# ``tests/fixtures/edifabric_api_key.txt`` (gitignored copy in
|
|
# production deployments — see Task 6 plan).
|
|
"edifabric.api_key": "CYCLONE_EDIFABRIC_API_KEY",
|
|
}
|