Files
python-sdk/pkg/hanzo-tools-api/hanzo_tools/api/storage.py
T
zooqueenandHanzo Dev b144973bfc fix(lint): resolve all ruff + black errors across all packages
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310,
S306, S110, TID251, UP042, F401) and run black on 526 files.

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-21 23:32:32 -08:00

327 lines
9.8 KiB
Python

"""Pluggable credential storage interface.
Provides abstractions for storing and retrieving credentials,
with built-in implementations for file storage and environment variables.
Custom implementations can integrate with KMS, 1Password, OS keychain, etc.
"""
from __future__ import annotations
import base64
import json
import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
import aiofiles
from .models import Credential
logger = logging.getLogger(__name__)
class CredentialStorage(ABC):
"""Abstract interface for credential storage.
Implement this interface to integrate custom secret stores
(e.g., AWS Secrets Manager, HashiCorp Vault, 1Password, OS keychain).
"""
@abstractmethod
async def get(self, provider: str) -> Credential | None:
"""Retrieve credential for a provider.
Args:
provider: Provider name
Returns:
Credential if found, None otherwise
"""
pass
@abstractmethod
async def set(self, credential: Credential) -> None:
"""Store a credential.
Args:
credential: Credential to store
"""
pass
@abstractmethod
async def delete(self, provider: str) -> bool:
"""Delete a stored credential.
Args:
provider: Provider name
Returns:
True if deleted, False if not found
"""
pass
@abstractmethod
async def list(self) -> list[str]:
"""List all stored provider names.
Returns:
List of provider names with stored credentials
"""
pass
class FileCredentialStorage(CredentialStorage):
"""File-based credential storage with obfuscation.
Stores credentials in a JSON file with basic base64 obfuscation
and restrictive file permissions (0600).
Note: This is NOT encryption. For production use with sensitive
credentials, consider using a proper secrets manager.
"""
def __init__(self, path: Path | None = None):
"""Initialize file storage.
Args:
path: Path to credentials file. Defaults to ~/.hanzo/api/credentials.json
"""
self.path = path or Path.home() / ".hanzo" / "api" / "credentials.json"
self._cache: dict[str, Credential] = {}
self._loaded = False
def _ensure_dir(self) -> None:
"""Ensure parent directory exists."""
self.path.parent.mkdir(parents=True, exist_ok=True)
@staticmethod
def _obfuscate(value: str) -> str:
"""Basic obfuscation for at-rest storage."""
return base64.b64encode(value.encode()).decode()
@staticmethod
def _deobfuscate(value: str) -> str:
"""Reverse obfuscation."""
try:
return base64.b64decode(value.encode()).decode()
except Exception:
return value
async def _load(self) -> None:
"""Load credentials from file."""
if self._loaded:
return
self._ensure_dir()
if self.path.exists():
try:
async with aiofiles.open(self.path) as f:
content = await f.read()
data = json.loads(content)
for name, cred_data in data.items():
# Deobfuscate sensitive fields
if cred_data.get("api_key"):
cred_data["api_key"] = self._deobfuscate(cred_data["api_key"])
if cred_data.get("api_secret"):
cred_data["api_secret"] = self._deobfuscate(
cred_data["api_secret"]
)
self._cache[name] = Credential(**cred_data)
except Exception as e:
logger.warning(f"Failed to load credentials: {e}")
self._loaded = True
async def _save(self) -> None:
"""Save credentials to file."""
self._ensure_dir()
data = {}
for name, cred in self._cache.items():
cred_dict = cred.model_dump()
# Obfuscate sensitive fields
if cred_dict.get("api_key"):
cred_dict["api_key"] = self._obfuscate(cred_dict["api_key"])
if cred_dict.get("api_secret"):
cred_dict["api_secret"] = self._obfuscate(cred_dict["api_secret"])
data[name] = cred_dict
async with aiofiles.open(self.path, "w") as f:
await f.write(json.dumps(data, indent=2))
# Set restrictive permissions
self.path.chmod(0o600)
async def get(self, provider: str) -> Credential | None:
await self._load()
return self._cache.get(provider)
async def set(self, credential: Credential) -> None:
await self._load()
self._cache[credential.provider] = credential
await self._save()
async def delete(self, provider: str) -> bool:
await self._load()
if provider in self._cache:
del self._cache[provider]
await self._save()
return True
return False
async def list(self) -> list[str]:
await self._load()
return list(self._cache.keys())
class EnvironmentCredentialStorage(CredentialStorage):
"""Environment variable credential storage (read-only).
Looks up credentials from environment variables based on
provider-specific mappings.
"""
def __init__(self, env_mappings: dict[str, list[str]] | None = None):
"""Initialize environment storage.
Args:
env_mappings: Mapping of provider names to environment variable names
"""
from .providers import ENV_VAR_MAPPINGS
self.env_mappings = env_mappings or ENV_VAR_MAPPINGS
async def get(self, provider: str) -> Credential | None:
env_vars = self.env_mappings.get(provider, [])
for var in env_vars:
value = os.environ.get(var)
if value:
logger.debug(f"Found credential for {provider} from env var {var}")
return Credential(provider=provider, api_key=value)
return None
async def set(self, credential: Credential) -> None:
"""Environment storage is read-only."""
raise NotImplementedError("Cannot write to environment storage")
async def delete(self, provider: str) -> bool:
"""Environment storage is read-only."""
raise NotImplementedError("Cannot delete from environment storage")
async def list(self) -> list[str]:
"""List providers with credentials in environment."""
result = []
for provider, env_vars in self.env_mappings.items():
for var in env_vars:
if os.environ.get(var):
result.append(provider)
break
return result
class MemoryCredentialStorage(CredentialStorage):
"""In-memory credential storage.
Useful for per-call overrides and testing.
Credentials are lost when the process exits.
"""
def __init__(self):
self._credentials: dict[str, Credential] = {}
async def get(self, provider: str) -> Credential | None:
return self._credentials.get(provider)
async def set(self, credential: Credential) -> None:
self._credentials[credential.provider] = credential
async def delete(self, provider: str) -> bool:
if provider in self._credentials:
del self._credentials[provider]
return True
return False
async def list(self) -> list[str]:
return list(self._credentials.keys())
class ChainedCredentialStorage(CredentialStorage):
"""Chains multiple credential stores with priority.
Checks stores in order and returns the first match.
Writes go to the first writable store.
Default order:
1. Memory (per-call overrides)
2. File storage
3. Environment variables
"""
def __init__(self, stores: list[CredentialStorage] | None = None):
"""Initialize chained storage.
Args:
stores: List of storage backends in priority order.
Defaults to [Memory, File, Environment].
"""
if stores is None:
stores = [
MemoryCredentialStorage(),
FileCredentialStorage(),
EnvironmentCredentialStorage(),
]
self.stores = stores
async def get(self, provider: str) -> Credential | None:
"""Get credential from first store that has it."""
for store in self.stores:
cred = await store.get(provider)
if cred and cred.has_credentials:
return cred
return None
async def get_with_source(
self, provider: str
) -> tuple[Credential | None, CredentialStorage | None]:
"""Get credential and its source store."""
for store in self.stores:
cred = await store.get(provider)
if cred and cred.has_credentials:
return cred, store
return None, None
async def set(self, credential: Credential) -> None:
"""Store credential in first writable store."""
for store in self.stores:
try:
await store.set(credential)
return
except NotImplementedError:
continue
raise RuntimeError("No writable credential store available")
async def delete(self, provider: str) -> bool:
"""Delete from all writable stores."""
deleted = False
for store in self.stores:
try:
if await store.delete(provider):
deleted = True
except NotImplementedError:
continue
return deleted
async def list(self) -> list[str]:
"""List providers from all stores."""
providers = set()
for store in self.stores:
providers.update(await store.list())
return list(providers)