Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""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."
|
||||
|
||||
|
||||
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(
|
||||
base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest())
|
||||
)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user