Skip to content

fix(service-datasource): inject a bound secret on the mongodb DSN branch - #9042

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-8696-mongo-dsn-bound-secret
Aug 16, 2026
Merged

fix(service-datasource): inject a bound secret on the mongodb DSN branch#9042
qq9340100 merged 1 commit into
mainfrom
claude/issue-8696-mongo-dsn-bound-secret

Conversation

@qq9340100

@qq9340100qq9340100 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes#8696

The card's last arm. PR #8875 closed the mysql half with Part of; this closes mongodb, so the card closes with it.

The defect

buildMongoUrl's DSN branch returned the authored config.url verbatim and applied spec.secret nowhere. A mongo datasource that bound external.credentialsRef therefore connected with whatever the URL itself carried — which, since #8082 refuses a user:password@ userinfo at the publish door, is no credential at all. Re-measured on origin/main @ 792524c22 before any edit (the card's :542 was stale; the branch was at :613), mongodb 7.5.0:

config.url 'mongodb://app@db.internal:27017/app' + a bound secret
-> MongoClient credentials {username:'app', password:''}

The connect path is fail-closed on a ref it cannot resolve, so "the datasource connected" reads as "the bound credential was used". It was not — declared, resolved, injected into the factory, then dropped at the last call site with no diagnostic. The arm's behaviour was decided by whether the operator happened to author a URL: the composed branch five lines below has honoured the secret since #4410.

The fix: options.auth beside an unmodified url

MongoDBDriver already spreads config.options into MongoClientOptions, so auth is a live channel. Measured on mongodb 7.5.0 — the MongoClient constructor resolves credentials eagerly, so all of this is assertable with no server:

'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND
'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND (bound wins)
'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND (multi-host)
'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND
'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin

So the authored URL is handed over byte for byte — no rewrite, no re-encoding of the secret, no second dialect of mongodb://… in this repo. The multi-host and +srv forms ride through unharmed, which a rewrite could not have promised.

The userinfo username that auth also requires is read through the platform's own DSN grammar — urlUserinfoUsername from @objectstack/spec/data (#8876) — and percent-decoded at the call site, since the accessor returns the raw component by contract and the client decodes the same component from the URL itself (a%2Fb authenticates as a/b; handing the raw value through would authenticate as a different user). Hand-rolling that parse here is the shape #8082's ruling rejects by name, and new URL() cannot even parse the multi-host form this schema documents (ERR_INVALID_URL, measured).

Not reached by symmetry with mysql. The clients merge a DSN against explicit keys in opposite directions — pg merges parse(connectionString)over the explicit config, which is why the postgres arm looks correct and is broken one layer lower (#8873). Each arm's precedence is measured against its own client; this one needed a different mechanism from #8875's { uri, password } to reach the same precedence rule.

Two decisions, made deliberately

A URL that names no user gets nothing.auth is not constructible from a password alone (MongoParseError: credentials must be an object with 'username' and 'password' properties), and inventing an empty username is measurably worse than silence: mongodb://db.internal:27017/app carries no credentials at all today, and would carry {username:''} — a guaranteed handshake failure — if the arm injected regardless. Injection happens only where the URL already declares authenticated intent. That also matches what the composed branch has always done with the same input, so no new per-branch asymmetry is planted. Making that contradictory pair loud belongs at the authoring door, where both halves are visible at once — filed as #9041, not guessed at here.

An embedded userinfo password is left in place. The bound secret wins over it anyway (measured above), so removing it would be a URL rewrite bought for nothing. Same precedence the mysql arm states, reached by a different mechanism.

The pin

Extends __tests__/bound-secret-dsn-branches.test.ts — the mysql half's own file — rather than opening a parallel one. Every mongo assertion reads MongoClient's resolved credentials, never the URL string this factory built: a test asserting buildMongoUrl's return value would have passed throughout this defect's life, and the postgres arm passes the equivalent config-layer assertion while still being broken below it.

Reverse verification, direction predicted in writing first (recorded in the file header before the run): with the pre-fix arm restored and the tests at their fixed state, the six injecting cases go RED on the password and the five remaining cases stay GREEN — the passthrough-preservation control, the two no-injection cases where both versions agree, and the two composed-branch controls, because the defect is branch-local. Measured exactly that: 6 failed / 5 passed.

× injects the bound secret beside the DSN instead of dropping it
- "password": "s3cr3t-from-sys_secret" + "password": ""
× lets the bound secret win over a legacy password embedded in a stored DSN
+ "password": "embedded-legacy"
× wins over an `auth` block written into the `options` passthrough
+ "password": "from-passthrough"

The fix was committed before the ablation; restored with git checkout HEAD -- ..., git status --porcelain clean afterwards (no MM).

Verification

Union re-run after the final commit, at HEAD 7cd52556c, workspace closure built first. Gate output redirected to files and each exit code read — never piped.

  • pnpm --filter @objectstack/service-datasource testTest Files 19 passed (19) / Tests 443 passed (443); typecheck clean.
  • Derived union (node scripts/pm/dispatch-gates.mjs over the three changed paths), all green: check:changeset-gate-self-tests, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, plus the convention-triggered check:query-options-erasure, check:type-check-coverage, check:type-check-debt --re-measure (33 ledger entries re-measured, none above its recorded number), check:engine-double-contract, check:where-matcher.
  • Added beyond the derivation, all green: check:slot-lookup (unnamed for packages/** source changes and known to redden CI anyway), check:nul-bytes (any edit), check:error-code-casing — no error code is minted here, this arm's behaviour carries no new code, so it is a confirmation rather than a requirement.

Blast radius is exactly the broken class: a datasource that binds no secret reaches the client byte-for-byte as before, and the options passthrough keeps arriving verbatim — the injected auth is merged into it, not assigned over it, and a resolved credentialsRef outranks an auth block written there by hand.

Filed in passing, unassigned, not fixed here


Generated by Claude Code

…nch (#8696)
buildMongoUrl's DSN branch returned the authored config.url verbatim and never
applied spec.secret, so a mongo datasource that bound external.credentialsRef
connected with whatever the URL carried — i.e. no credential at all, since
#8082 refuses a user:password@ userinfo at the publish door.
The credential now rides beside an unmodified url as MongoClient's `auth`
option: no URL rewrite, so the multi-host and +srv forms are unharmed and no
second dialect of mongodb:// enters this repo. The userinfo username `auth`
also requires is read through @objectstack/spec/data's urlUserinfoUsername
(#8876) and percent-decoded at the call site. A url naming no user is left
alone rather than given a fabricated empty username.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms

2 participants

@qq9340100@claude