Per the Microcopy rule: size and duration errors, the flavor errors, run parameter errors, the Resources field descriptions, 401/409/400 details, secret errors, the websocket node description and its on_connect setting. The generated client follows. Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
"""Encrypted credential store for node integrations.
|
|
|
|
Nodes never hold credentials: a parameter written as ``{"$secret": "name"}``
|
|
is replaced with the stored value when the node is built. Secrets are kept
|
|
encrypted outside the flows repository, so what gets committed and shared
|
|
never contains a password.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
|
|
SECRET_REF = "$secret"
|
|
|
|
|
|
class SecretsUnreadable(RuntimeError):
|
|
"""The store exists but does not decrypt with the current SECRET_KEY."""
|
|
|
|
|
|
class SecretNotFound(KeyError):
|
|
"""A node asked for a secret that is not in the store."""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
super().__init__(name)
|
|
self.name = name
|
|
|
|
def __str__(self) -> str:
|
|
return f"No secret named '{self.name}'. Add it under Secrets."
|
|
|
|
|
|
def fernet(key: str) -> Fernet:
|
|
"""The cipher a store keyed on the app key uses, so every one of them
|
|
turns unreadable together when ``SECRET_KEY`` changes."""
|
|
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()))
|
|
|
|
|
|
class SecretsStore:
|
|
"""Named credentials, encrypted at rest with a key derived from the app key."""
|
|
|
|
def __init__(self, path: Path, key: str) -> None:
|
|
self._path = path
|
|
self._fernet = fernet(key)
|
|
|
|
def _read(self) -> dict[str, str]:
|
|
if not self._path.exists():
|
|
return {}
|
|
try:
|
|
data: dict[str, str] = json.loads(
|
|
self._fernet.decrypt(self._path.read_bytes())
|
|
)
|
|
return data
|
|
except (InvalidToken, ValueError) as exc:
|
|
# A changed app key makes existing secrets unreadable. Fail loudly:
|
|
# flows coming up with silently missing credentials are far harder
|
|
# to diagnose than a node that reports why it did not load.
|
|
raise SecretsUnreadable(
|
|
f"Cannot decrypt {self._path}. It was written with a "
|
|
"different SECRET_KEY. Re-encrypt it with "
|
|
"scripts/rotate_secret_key.py."
|
|
) from exc
|
|
|
|
def _write(self, data: dict[str, str]) -> None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._path.write_bytes(self._fernet.encrypt(json.dumps(data).encode()))
|
|
|
|
def list(self) -> list[str]:
|
|
return sorted(self._read())
|
|
|
|
def get(self, name: str) -> str:
|
|
data = self._read()
|
|
if name not in data:
|
|
raise SecretNotFound(name)
|
|
return data[name]
|
|
|
|
def set(self, name: str, value: str) -> None:
|
|
data = self._read()
|
|
data[name] = value
|
|
self._write(data)
|
|
|
|
def delete(self, name: str) -> None:
|
|
data = self._read()
|
|
if data.pop(name, None) is None:
|
|
raise SecretNotFound(name)
|
|
self._write(data)
|
|
|
|
|
|
_store: SecretsStore | None = None
|
|
|
|
|
|
def init_secrets(path: Path, key: str) -> SecretsStore:
|
|
"""Create the process-wide store (called once at startup)."""
|
|
global _store
|
|
_store = SecretsStore(path, key)
|
|
return _store
|
|
|
|
|
|
def get_secrets() -> SecretsStore:
|
|
if _store is None:
|
|
raise RuntimeError("Secrets store not initialised")
|
|
return _store
|
|
|
|
|
|
def get(name: str) -> str:
|
|
"""Look up a secret by name — the entry point for custom node code."""
|
|
return get_secrets().get(name)
|
|
|
|
|
|
def resolve_params(params: Any) -> Any:
|
|
"""Replace every ``{"$secret": "name"}`` reference with its value."""
|
|
if isinstance(params, dict):
|
|
if set(params) == {SECRET_REF} and isinstance(params[SECRET_REF], str):
|
|
return get(params[SECRET_REF])
|
|
return {k: resolve_params(v) for k, v in params.items()}
|
|
if isinstance(params, list):
|
|
return [resolve_params(v) for v in params]
|
|
return params
|