Source code for fenn.remote.credentials

"""Profile-aware credentials store for the Fenn remote service.

The credentials file lives at ``~/.fenn/credentials`` and follows a TOML-like
flat profile layout, mirroring the AWS CLI's ``~/.aws/credentials``::

    [default]
    api_key = "fk_live_..."

    [work]
    api_key = "fk_live_..."

API key resolution order (highest priority first):

1. Explicit ``--api-key`` flag (passed in by the caller).
2. ``FENN_API_KEY`` environment variable.
3. ``~/.fenn/credentials`` ``[profile]`` section.
4. ``.env`` via :class:`fenn.secrets.keystore.KeyStore`.

Reads use stdlib ``tomllib`` (Python >=3.11). Writes use a small hand-rolled
serializer so we do not need to add ``tomli_w`` as a dependency.
"""

from __future__ import annotations

import os
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional

from fenn.remote.exceptions import CredentialsError

DEFAULT_PROFILE = "default"
CREDENTIALS_DIR = Path.home() / ".fenn"
CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials"
ENV_API_KEY = "FENN_API_KEY"
ENV_PROFILE = "FENN_PROFILE"


[docs] @dataclass class Credentials: """A single resolved profile entry.""" profile: str api_key: str host: Optional[str] = None
def _read_file() -> Dict[str, Dict[str, str]]: if not CREDENTIALS_FILE.exists(): return {} try: with open(CREDENTIALS_FILE, "rb") as f: data = tomllib.load(f) except (OSError, tomllib.TOMLDecodeError) as exc: raise CredentialsError( f"Failed to read credentials file {CREDENTIALS_FILE}: {exc}" ) from exc out: Dict[str, Dict[str, str]] = {} for profile, section in data.items(): if not isinstance(section, dict): continue out[profile] = {str(k): str(v) for k, v in section.items()} return out
[docs] def load_credentials(profile: str = DEFAULT_PROFILE) -> Optional[Credentials]: """Return the ``[profile]`` section, or ``None`` if missing.""" data = _read_file() section = data.get(profile) if section is None or "api_key" not in section: return None return Credentials( profile=profile, api_key=section["api_key"], host=section.get("host"), )
[docs] def write_credentials( api_key: str, *, profile: str = DEFAULT_PROFILE, host: Optional[str] = None, ) -> Path: """Persist ``api_key`` under ``[profile]``. ``host`` is retained for backward-compatible callers; the CLI uses the fixed remote endpoint from :mod:`fenn.remote.client`. Existing profiles are preserved. The file is created with mode ``0o600`` on POSIX; on Windows the umask is left to the OS (the file lives under the user's home, which is already user-private). """ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True) data = _read_file() data[profile] = {"api_key": api_key} if host: data[profile]["host"] = host serialized = _serialize(data) CREDENTIALS_FILE.write_text(serialized, encoding="utf-8") if sys.platform != "win32": try: os.chmod(CREDENTIALS_FILE, 0o600) except OSError: pass return CREDENTIALS_FILE
def delete_profile(profile: str = DEFAULT_PROFILE) -> bool: """Remove ``[profile]`` from the credentials file. Returns ``True`` if removed.""" data = _read_file() if profile not in data: return False del data[profile] if data: CREDENTIALS_FILE.write_text(_serialize(data), encoding="utf-8") else: try: CREDENTIALS_FILE.unlink() except FileNotFoundError: pass return True def _serialize(data: Dict[str, Dict[str, str]]) -> str: """Render the profile dict back into TOML. Only string values are supported; keys are constrained to a small ASCII set so we can emit them as bare keys. Values are emitted as basic strings with backslash escaping. """ out: list[str] = [] for profile in sorted(data.keys()): out.append(f"[{profile}]") for key in sorted(data[profile].keys()): value = data[profile][key] out.append(f"{key} = {_toml_string(value)}") out.append("") return "\n".join(out).rstrip() + "\n" def _toml_string(value: str) -> str: escaped = ( value.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") ) return f'"{escaped}"'
[docs] def resolve_api_key( *, explicit: Optional[str] = None, profile: Optional[str] = None, ) -> Credentials: """Resolve an API key using the documented priority chain. Raises: CredentialsError: if no key can be resolved. """ profile = profile or os.getenv(ENV_PROFILE) or DEFAULT_PROFILE if explicit: return Credentials(profile=profile, api_key=explicit, host=None) env_value = os.getenv(ENV_API_KEY) if env_value: return Credentials(profile=profile, api_key=env_value, host=None) creds = load_credentials(profile) if creds is not None: return creds try: from fenn.secrets.keystore import KeyStore dotenv_value = KeyStore().get_key(ENV_API_KEY) except KeyError: dotenv_value = None except Exception: dotenv_value = None if dotenv_value: return Credentials(profile=profile, api_key=dotenv_value, host=None) raise CredentialsError( "No Fenn API key found. Run `fenn auth login` to save one, " f"or set the {ENV_API_KEY} environment variable." )
def mask_key(api_key: str) -> str: """Return a display-safe rendering of ``api_key`` (first 8 + last 4 chars).""" if len(api_key) <= 12: return "*" * len(api_key) return f"{api_key[:8]}...{api_key[-4:]}"