fix(service-datasource): a bound credentialsRef reaches the mysql client on the DSN branch (#8696) - #8875
Merged
Merged
Conversation
…ent on the DSN branch (#8696) `DatasourceConnectionService` resolves `external.credentialsRef` to a cleartext secret and hands it to the driver factory as `spec.secret`. The mysql arm threw it away whenever `config.url` was present -- `if (url) return url;` made the DSN string the whole knex `connection`, so the resolved credential reached nothing. Since #8082 refuses a `user:password@` userinfo at the publish door, a bare username DSN plus a bound secret is the only authorable URL shape for this driver, so the arm dropped the credential of precisely the configuration the platform tells operators to write: the datasource connected unauthenticated, or failed with a driver-level auth error naming nothing about the binding. The fix hands mysql2 the DSN and the secret together (`{ uri, password }`) rather than parsing the URL here: mysql2 keeps owning its own DSN grammar, and its merge gives the explicit key precedence, so the bound secret also wins over a legacy password embedded in a stored pre-#8082 row. A DSN with nothing bound still passes through as the bare string, so nothing that binds no secret changes behaviour. Measured on mysql2 3.23.1, knex 3.3.0 and pg 8.22.0. The mongodb arm and a separate pg client-layer defect are recorded in the changeset and left to their own changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
…8696 pin header The reverse-verification note carried the predicted failure text rather than the observed one. Replaced with the two real assertion messages from the ablated run (2 failed / 3 passed, the predicted set). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
…dentialsref-dsn-arms
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 1 changed package(s). ✅ |
This was referenced Aug 15, 2026
qq9340100
marked this pull request as ready for review
August 15, 2026 12:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #8696 — the mysql arm only. The mongodb arm named in that card is not implemented here and #8696 remains open for it; see "What is deliberately not in this PR" below.
Premise re-verified first (the card was deferred behind #8081)
#8696 was held for a day because #8081 was rewriting the datasource credential read/write paths this defect sits on, so the first deliverable was a measurement, not a fix. Measured on
origin/main@20067c56b, driver arm by driver arm, with a bound secret injected exactly asDatasourceConnectionServiceinjects it:The premise still holds. #8081 did not touch these branches — the three sites the card cites (
:442,:542,:389) are unchanged, andDatasourceConnectionServicestill resolvesexternal.credentialsRefintospec.secretand hands it to the factory.The fork the card left open is settled by the spec, not by preference
The card asked whether to inject the secret or refuse loudly, and the dispatch treated refusal as presumptive. Both readings turned out to be answerable from the contract rather than by taste:
packages/specalready declares injection on all three URL-bearing SQL arms.MysqlConfigSchema.url, verbatim:The same sentence appears on
MongoConfigSchema.urlandPostgresConfigSchema.url. So this is not a fork between two defensible behaviours — it is Prime Directive #10 one layer down: the contract says injected at connect time, and the runtime dropped it. Refusing loudly would have required changing the spec, which is adomain:speccontract decision, and would additionally have rejected the exact configurationdatasource-credential-migration.ts(landed today in #8155) instructs operators to write:That same file names the remedy this PR implements — "the driver factory's DSN branches would have to honour the injected secret" — as the producer-side change its own refusal is waiting on.
The dispatch's stop-condition did not fire. It was "stop and report if a URL rewrite is required for mongo": measured false.
MongoClient'sauthoption injects beside an unmodified url and wins over an embedded userinfo password (mongodb 7.5.0), so no rewrite and no URL-encoding of the secret is involved on either arm.The change
buildMysqlConnection's DSN branch wasif (url) return url;— the DSN string became the whole knexconnection, which has no key a credential could live in. It now hands mysql2 the DSN and the secret together:{ uri, password }rather than a hand-parsed URL is the point of the shape: mysql2 keeps owning its own DSN grammar, so there is no second dialect ofmysql://…in this repo, no re-encoding, and no place for a parser bug to change which host is connected to. Measured on mysql2 3.23.1 / knex 3.3.0 / pg 8.22.0:That last line is a trap worth naming: knex stores
passwordas a non-enumerable own property (setHiddenProperty), soJSON.stringifyandObject.keysboth report a bare{uri}. A serialising probe reads as "the secret was dropped" when it was not — every assertion in the pin uses direct property access instead.Blast radius is exactly the broken class. A DSN with nothing bound still returns the bare string it always did, so any datasource that binds no secret is byte-for-byte unaffected. The only datasources whose behaviour changes are the ones that bound a credential and were connecting without it.
Verification
bound-secret-dsn-branches.test.ts— 5 cases: injection on the DSN branch, bound-wins-over-embedded-legacy-DSN, the no-secret passthrough, and two controls on the discrete branch.if (url) return url;turns the two DSN cases RED and leaves the no-secret and both discrete cases GREEN — branch-local, so a whole-arm regression would mean the pin measures the wrong thing. Measured exactly that set, 2 failed / 3 passed:@objectstack/service-datasource: 426 tests / 19 files pass,typecheckclean.scripts/pm/dispatch-gates.mjsand run at9e0e95f87, which is this branch's head (the union was re-run after mergingorigin/main, since the merge commit moved the head):check:test-source-alias,check:type-source-resolution,check:nul-bytes,check:error-code-casing,check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset— all green. The re-derivation added seven families beyond the dispatch list (the changeset family and the test-file convention ratchets).No new error code is minted, so nothing lands on the ADR-0112 surface: the refusals this file already raises are plain errors carrying a fix instruction, and this change adds none.
What is deliberately not in this PR
The mongodb arm — #8696 stays open for it.
buildMongoUrl'sif (explicit) return explicit;still drops the bound secret, so a mongo DSN datasource still reaches MongoClient with an empty password (measured). The remedy is known and cheap —options.auth, whichMongoDBDriveralready forwards verbatim — butauthrequires a username as well as a password, and reading the url's userinfo username needs the platform's own DSN grammar:new URL()rejects the multi-host formMongoConfigSchemadocuments (mongodb://app@h1:27017,h2:27017/appthrowsERR_INVALID_URL, measured), which is precisely why@objectstack/spec/dataownsurlUserinfoPassword/redactUrlPasswordinstead of using WHATWG parsing. Those two export the password half of that grammar and no username half. Adding one belongs beside them inpackages/spec, which is outside the file surface this card was dispatched with, and hand-rolling a second copy of the userinfo boundaries here is the shape #8082's ruling rejected by name ("a single value-level parse as the mechanism, precisely so no second copy exists to disagree with this one"). Left for an explicit decision rather than guessed at; the reasoning is recorded onbuildMongoUrlitself so the next author does not have to re-derive it.Two defects measured in passing, filed unassigned rather than fixed here:
external.credentialsRefreaches the knex config but NOT the server on the postgres DSN branch —pgoverrides it with the connection string #8873 — the postgres arm passes the equivalent assertion at the config layer and is broken one layer below it.pgmergesparse(connectionString)over the explicit config, so the injected password resolves to the DSN's own — effectivepassword: nullon a credential-free DSN (pg 8.22.0). This falsifies the "postgres is the reference implementation" premise that A boundexternal.credentialsRefis silently dropped on the DSN branches of the mysql and mongodb driver arms #8696 and its triage both carry, and it is why the pin here asserts what it does. The two clients merge in opposite directions, so that arm must not be fixed by symmetry with this one.sslblock is silently dropped on the mysql arm's DSN branch (postgres honours it there) #8874 — the same mysql DSN branch also drops a declaredsslblock (resolveSslOptionis computed and then returned past), while the postgres branch carries it deliberately. Not folded in here: making TLS appear only for datasources that happen to bind a credential would be a stranger asymmetry than the one being fixed.Generated by Claude Code