Skip to content

Resolve keychain credentials on zero-persistence projects - #5257

Merged
stuartc merged 4 commits into
mainfrom
frank/ofn-4506
Oct 23, 2026
Merged

stuartc merged 4 commits into
mainfrom
frank/ofn-4506

Conversation

@midigofrank

@midigofrank midigofrank commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

When a project doesn't save dataclips, the run's input is wiped as soon as the worker fetches it. That happens before the worker asks for any credential, so keychain resolution found nothing to match against and fell back to the default credential.

The run channel now reads each keychain's external id from the input just before the wipe, and keeps the result in the socket's assigns.

Each keychain's path is evaluated in its own query. The changeset only checks paths with a loose regex, so a path Postgres can't parse (like $..user_id) can be saved.

Credential lookups for a run now also check ids against the run's snapshot, not the live workflow. The worker was given the ids in the snapshot, so those are the ones it can ask for, even if someone has edited the job since.

Closes #5091

Validation steps

  1. In a project's settings, under Data Storage, choose "Never retain input/output data (zero-persistence)".
  2. Add three raw credentials to the project: one with External ID alice and body {"alice_key": "x"}, one with External ID bob and body {"bob_key": "x"}, and one with no External ID and body {"default_key": "x"}.
  3. Create a keychain credential with path $.user_id and the third credential as its default.
  4. Build a webhook-triggered workflow with a job that uses the keychain and logs which credential it got: fn(state => { console.log(Object.keys(state.configuration)); return state; }). (Keys aren't scrubbed from logs, values are.)
  5. POST {"user_id": "alice"} to the webhook. The run log shows alice_key. On main it shows default_key. Then POST {"user_id": "bob"} and check for bob_key.
  6. Edit the keychain path to $..user_id (the form accepts it) and add a second job off the trigger that doesn't use the keychain. POST again: the keychain job fails with "Could not select a keychain credential: its path could not be evaluated against the run's input", and the other job still runs.

Additional notes for the reviewer

  1. Known limitation: a worker that rejoins mid-run can't use keychain credentials on zero-persistence projects. The selected ids live only in the channel process. A rejoin starts a new channel, and by then the input has been wiped, so every later keychain credential request fails with "Could not select a keychain credential". This is deliberate: it errors rather than quietly handing over the default. Fixing it properly means saving the selection on the run before the wipe, which needs a migration. I've left that for a follow-up.

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

When a project doesn't save dataclips, the run's input is wiped as soon
as the worker fetches it. That happens before the worker asks for any
credential, so keychain resolution found nothing to match against and
fell back to the default credential.

The run channel now reads each keychain's external id from the input
just before the wipe, and keeps the result in the socket's assigns. If
those ids are missing on a zero-persistence run (for example a rejoin
after the wipe), the channel returns an error instead of quietly handing
over the default.

Credential lookups for a run now also check ids against the run's
snapshot, not the live workflow. The worker was given the ids in the
snapshot, so those are the ones it can ask for, even if someone has
edited the job since.
@github-project-automation github-project-automation Bot moved this to New Issues in Core Oct 22, 2026
The changeset checks keychain paths with a loose regex, so paths
Postgres can't parse, like `$..user_id`, can be saved. Evaluating
every keychain in one query meant a single bad path raised in
fetch:dataclip and failed the whole run on zero-persistence projects,
including jobs that never touch a keychain.

Each keychain's path is now evaluated in its own query. A Postgres
error marks only that keychain, and the job that asks for it gets an
error rather than the default credential, since a broken path is a
config mistake, not missing input.

Fetching a keychain credential on projects that save dataclips now
evaluates only that keychain's path, and runs whose snapshot has no
keychain jobs skip the query altogether.
The default-argument head made resolve_credential/3 accept a
%Credential{} with a selectors map, which only raised
FunctionClauseError, and its spec failed dialyzer. Credentials and runs
now share /2, and /3 takes a run only.

With that contract fixed, dialyzer flagged the run channel's fallback
error clause as unreachable. It isn't: resolve_credential_body/2 passes
through any other OAuth refresh failure, such as a timeout's error map,
or a changeset when the new token can't be saved. Its spec now says so.
@midigofrank
midigofrank marked this pull request as ready for review October 22, 2026 09:25
@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.5%. Comparing base (3b9f4a2) to head (a83742a).

Additional details and impacted files
@@          Coverage Diff          @@
##            main   #5257   +/-   ##
=====================================
  Coverage   91.4%   91.5%           
=====================================
  Files        465     465           
  Lines      23416   23453   +37     
=====================================
+ Hits       21409   21450   +41     
+ Misses      2007    2003    -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

I have everything I need. All three checks resolve cleanly:

  • S0: The new get_run_credential query (lib/lightning/credentials/resolver.ex:271) enforces pc.project_id == w.project_id for the project credential, and get_run_keychain (lib/lightning/credentials/resolver.ex:289) only exposes default_credential when its ProjectCredential matches the workflow's project. find_credential_by_external_id (lib/lightning/credentials/resolver.ex:237) continues to walk the association chain run → work_order → workflow → project → project_credentials → credential, so the external-id branch stays project-scoped. The new select_external_id query only extracts a path string from the run's own dataclip — no cross-project data flows out.
  • S1: No new web-layer actions. fetch:credential and fetch:dataclip on run_channel.ex are existing worker-authenticated endpoints; the diff only re-plumbs how keychain selectors are threaded through.
  • S2: No new Repo.insert/update/delete on config resources — the resolver is read-only, and the only mutation (Runs.wipe_dataclips/1) was already present.

Security Review ✅

  • S0 (project scoping): New get_run_credential / get_run_keychain queries at lib/lightning/credentials/resolver.ex:271,289 enforce pc.project_id == w.project_id and keychain default-credential project membership; external-id lookup remains scoped via the run → project → project_credentials association.
  • S1 (authorization): N/A, no new web-layer actions — only existing worker-authenticated fetch:credential / fetch:dataclip handlers are modified.
  • S2 (audit trail): N/A, the resolver is read-only and the PR introduces no config-resource writes.

@midigofrank
midigofrank requested a review from stuartc October 22, 2026 09:59
@midigofrank midigofrank self-assigned this Oct 22, 2026
A second fetch:dataclip on a zero-persistence run recomputed the keychain
selections from the already-wiped body. Every path then matched nothing,
the good selections were overwritten, and the job quietly got the default
credential.

The channel now captures selections only on the first fetch, and the
resolver treats a dataclip with wiped_at set as unavailable input, so a
rejoined channel errors too.
@stuartc
stuartc merged commit 77d43a2 into main Oct 23, 2026
5 of 6 checks passed
@stuartc
stuartc deleted the frank/ofn-4506 branch October 23, 2026 11:47
@github-project-automation github-project-automation Bot moved this from New Issues to Done in Core Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Keychain credentials do not resolve on projects with zero-persistence enabled

2 participants