Skip to content

fix(service-datasource): stop serving stored credentials on the datasource read path, and fix the false "credential-stripped" claim (#8081) - #8126

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-8081-datasource-credential-read-path
Aug 12, 2026
Merged

fix(service-datasource): stop serving stored credentials on the datasource read path, and fix the false "credential-stripped" claim (#8081)#8126
huangyiirene merged 1 commit into
mainfrom
claude/issue-8081-datasource-credential-read-path

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes nothing on its own — #8081 stays open until scope items 3/4/5 are dispositioned, and #7990 stays open behind it. Per the domain:services dispatch order (#8081 comment 5269562252), this PR delivers scope items 1 and 2 only; 3/4/5 are reported on the issue, not implemented.

Item 1 — read-path scrub (delivered)

DatasourceAdminService.getDatasource() returned the driver configverbatim (datasource-admin-service.ts:199) while :179 carried a doc comment promising "with the credential stripped" and describing config as "non-sensitive — credentials live in sys_secret, never in config". Nothing stripped anything.

The comment was not merely stale, it was load-bearing — it is why the gap survived a 26-surface credential survey. It is rewritten to say what the code does, and to record what it used to claim.

What is redacted, and where the list comes from. The refused-key set is derived from each driver's own contract rather than retyped: #8078 spells a refused inline credential as z.never(), so the schema is the list, and a credential key refused tomorrow is covered the day it lands. Three sources feed the scrub:

sourcekeyswhy
derived (z.never())password (postgres/mysql/mongodb), authToken (turso)#8078's own refusal shape
pre-#8078 aliasespasswdpwdtokenjwtauth_tokenauthtokena stored row never met the parse that renamed them — the wizard persists through metadata.register
still-writable but credential-shapedturso encryptionKeyAES-256 key the binder has no slot for; redacting on read neither grants nor removes that slot

A driver the platform ships no contract for still has the canonical spellings hidden by name. Declining to refuse an unrecognised key is a boundary choice about authoring; serving a key literally named password back in cleartext is a leak under any boundary.

URL-embedded credentials — the explicit reading the dispatch asked for. A postgresql://user:pass@host/db in config.url carries the same secret as config.password. Measured on origin/main, getDatasource() served both for the same row. A scrub stopping at config.password would have been half a scrub — the same "claims a protection it does not perform" shape this card exists to end. So the read path redacts the password component of the userinfo and leaves scheme, username and everything from the host onward byte-for-byte.

⛔ Nothing here refuses a URL, at any door. #7990 left that unruled and #8078 pinned the acceptance as a FACT; a pin guards that boundary and passes on both sides. Redacting a value on the way out is a different act from refusing it on the way in, and only the second one is unruled.

The response names what it withheldredactedConfigKeys, the same courtesy hasSecret already pays for the bound handle.

Why the scrub needed an inverse. The edit form reads this config and patches it straight back, so a scrub with no inverse turns every untouched "Save" into silent credential deletion — trading a disclosure bug for a data-loss bug. updateDatasource therefore carries the hidden material forward on a same-driver round-trip, after the validation gate: the gate judges what the author wrote, and this is material the author never saw and is not asking to change. Same rule the credentialsRef beside it has always followed.

That also repairs a regression #8078 introduced, measured here for the first time: on main the form was served config.password verbatim, posted it back unchanged, and the write gate refused it — so editing any legacy datasource through the wizard answered 400 for a value the server itself had just supplied, including the active: false that takes a misconfigured datasource out of service.

Not changed: the stored record is never mutated. Redaction is a read-path act; the connect path reads the raw record and a legacy datasource keeps authenticating exactly as before. Getting cleartext out of the store is item 3's migration and is deliberately not attempted.

Item 2 — write-door measurement (measured; nothing to fix)

Measured end-to-end against the real HonoHttpServer, not reasoned from source:

doorlegacy inline payloadreading
POST /api/v1/datasources (wizard)config.password400 DATASOURCE_ADMIN_ERROR, full guidance in error.message
POST /api/v1/datasourcesalias passwd400 DATASOURCE_ADMIN_ERROR, guidance + unrecognised-key prose
PATCH /api/v1/datasources/:nameconfig.password400 DATASOURCE_ADMIN_ERROR, full guidance
PUT /metaconfig.password422 INVALID_METADATA + structured issues[] carrying the same message
datasource-admin plugin (service call)config.passwordthrows with the full guidance; it is the object the routes wrap

No door collapses into a generic 500. The refusal guidance — naming both the secret binder and external.credentialsRef — reaches the caller intact at every door. Both codes are already ADR-0112-registered (DATASOURCE_ADMIN_ERROR, INVALID_METADATA), so no envelope change was needed and none was made. The card's worry did not materialise; that is the finding.

Reverse verification

9 of 21 pins fail on origin/main, each naming the defect it carries; 21/21 pass here. Method: the service file was reverted to origin/main's version in-tree and the suite re-run, so the readings are measured, not asserted.

Red on main: cleartext password served · URL credential served · redactedConfigKeys absent · turso authToken/encryptionKey served · alias spellings served · served-vs-stored split · round-trip 400 · non-credential edit blocked · driver change.

Green on both sides, by design: #8078's parse refusal still fires with its guidance intact (guards the spec half) · a URL-embedded credential is still ACCEPTED at the write door (guards the unruled boundary).

One pin was rewritten mid-task because it passed vacuously on both sides — "the stored record is untouched" is green on main for the worst possible reason (nothing redacts there, so served and stored are identical). It now asserts the served and stored halves together, which makes it red on main. That is the #7801 failure mode, caught here.

Gates

  • check:test-source-aliasOK, 72 packages scanned. New test file; no new workspace dependency, no KNOWN_UNALIASED_TEST_IMPORTS edit.
  • check:type-check-coverageOK, 64/77 packages type-checked. service-datasource is type-checked, not ledgered; tsc --noEmit clean.
  • check:type-check-debt --re-measureOK on the built closure, 1966 raw errors, none above its recorded number. (The surplus notices are pre-existing entries below their ceiling, untouched by this PR.)
  • service-datasource suite: 361/361.
  • Changeset present. Untouched: content/docs/releases/**, docs/adr/**, .claude/skills/**, skills/**, sys-session.object.ts, packages/spec/src/**.

Reported on the issue, NOT implemented here

Items 3 (migration story), 4 (turso encryptionKey) and 5 (managed credential slot) are decisions belonging to other seats. Two findings from measuring them are load-bearing and are written up on #8081:


Generated by Claude Code

…ource read path, and fix the false "credential-stripped" claim (#8081)
`getDatasource()` returned the driver `config` verbatim while its own doc
comment promised "with the credential stripped" and described `config` as
"non-sensitive — credentials live in `sys_secret`, never in config". Nothing
stripped anything. The comment was load-bearing: it is why the gap survived a
26-surface credential survey.
#8078 closed the WRITE door (`config.password` / `config.authToken` are
`z.never()` on every driver that has them) and deliberately left stored rows
alone. Those rows still hold cleartext, and `GET /api/v1/datasources/:name`
handed it to every caller.
The refused-key set is DERIVED from each driver's contract — #8078 spells a
refused inline credential as `z.never()`, so the schema is the list — plus the
pre-#8078 alias spellings (a stored row never met the parse that renamed them)
and turso's still-writable `encryptionKey`. A driver with no shipped contract
keeps the canonical spellings hidden by name.
A credential embedded in `config.url` is redacted too, at the password
component of the userinfo only. Refusing such a URL at the write door stays
UNRULED (#7990) and untouched: redacting on the way out is not refusing on the
way in, and a scrub that dropped `config.password` while serving the same
secret one key over would be a scrub in name only.
`updateDatasource` carries the hidden material forward across a same-driver
round-trip, after the validation gate rather than before it — the gate judges
what the author wrote, and this is material the author never saw. Without it
the scrub would have turned every untouched "Save" into silent credential
deletion. It also repairs a regression #8078 introduced and this card measured:
the form was served `config.password` verbatim, posted it back, and the gate
refused it — so editing any legacy datasource answered 400 for a value the
server itself had just supplied.
The stored record is never mutated; the connect path is unaffected.
Reverse verification: 9 of 21 pins fail on `origin/main` (each naming the
defect it carries), 21/21 pass here. The two guard pins — #8078's refusal with
its guidance intact, and URL credentials still ACCEPTED at the write door —
pass on both sides by design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCoM9vVXw6yAVgEc4oio4m
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 5:03pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 1 changed package(s). ✅

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 17:21
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 37b82edAug 12, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-8081-datasource-credential-read-path branch August 12, 2026 17:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@huangyiirene