Version v0.4.6 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.
Handling secrets in a feature
If your change reads, writes, delivers, or displays a secret — a connection
URI, a generated profiles.yml, a keyfile, a token, an API key — it is bound by
the two invariants in ADR 0061. This page
is the practical how-to. Both rules exist because the same leak has happened three
times (#882,
PR #867 (field report #11),
GHSA-3r74-9w27-v32f) — each avoidable.
Rule 1 — Private locality: never write a secret where it could be committed
A secret-bearing file goes to a private, ephemeral scratch (0700, per-task,
removed when the task ends) — never the project directory, the process CWD,
the repo, or a committable dotfile. The default output location must be a private
mkdtemp, never os.getcwd() / ".".
leoflow provides the scratch on both execution paths — use it, don’t reinvent it:
- Pod: the base image sets
DBT_PROFILES_DIR/DBT_TARGET_PATH/DBT_LOG_PATHto/tmp/leoflow/...(runtime/Dockerfile). - Lite: the subprocess executor injects the same three at a per-task
MkdirTemp(internal/executor/subprocess.go,dbtScratchEnv) — this Lite-side injection lands with the #882 fix.
So a task reads DBT_PROFILES_DIR and writes there. The runtime’s own fallback,
for any path that forgot to set it, must be a private mkdtemp — never the CWD
(runtime/python/leoflow_runtime/__main__.py, _dbt_profiles_dir, also part of the
#882 fix):
d = os.environ.get("DBT_PROFILES_DIR")
if not d:
d = tempfile.mkdtemp(prefix="leoflow-dbt-") # NOT os.getcwd()
Symmetry matters. A fix that lands only in the pod base image but not in the Lite executor (or vice-versa) is incomplete — that asymmetry was #882. Whatever locality the pod gets, Lite gets the same.
Rule 2 — Masked on read: never echo a secret back
A read path — API response, UI, logs, audit, error message — never returns a secret. The write path accepts it; the read path masks it. Reuse the shared matcher rather than hand-rolling a key list:
// internal/api — mask secret-bearing keys on serialize; recurse into free-form blobs.
if isSensitiveKey(key) {
value = "***"
}
Prefer omitting a write-only field entirely (as the connection password is). For
a free-form blob like a connection’s extra, redact by key name recursively,
and fail closed (redact the whole thing) if it can’t be parsed.
The tests are part of the feature
- Writes a credential → a unit test proving the target is not the CWD/project
(and is
0700), plus an inner-loop e2e asserting nothing secret lands under the repo (seetest/e2e/lite-dbt.sh’s profiles-less assertion). - Reads a credential → a test proving the response is masked (see
TestConnectionGetMasksSensitiveExtra). - Surfaces in the UI → Playwright against a real backend, per the SPA testing
rule — the embedded Airflow UI reads the same
/api/v2/*, so a masked value must render masked there too.
If you’re adding a connector, an operator, or a task type that handles a credential and you’re unsure, treat ADR 0061 as the checklist: where does the secret land on disk, and can any read echo it?