Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
---
"@objectstack/spec": minor
---

feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a composed mongo config naming no `username`" at publish (#9147)

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`, like the sibling refusals #8337,
#9040 and #9041; the migration prescription is registered under protocol major
18, where `os migrate meta` users will look).

The COMPOSED-branch twin of #9041, and the last unserved corner of the "absence
must be loud" half of the #8696 family. #9041 refused a bound
`external.credentialsRef` beside a mongo `config.url` whose userinfo names no
user; its fences deliberately scoped that to the URL branch, leaving the same
defect one branch over still accepted:

```yaml
driver: mongodb
config: { database: events, host: mongo.internal }
external: { credentialsRef: sys_secret:01J9ZK4T2N }
```

With no `config.url` the driver factory COMPOSES the connection URI from the
discrete fields, and the bound secret has exactly one route into it — the
userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … :
''`). A falsy `username` closes that route, and this branch has no second one:
`buildMongoAuth` returns early when there is no `url`, because the composed
branch injects THROUGH the URI it builds rather than beside it. So the artefact
above parsed green, connected **anonymously**, and told the operator nothing —
byte for byte the defect #9041 closed, one branch over. Both branches were
measured to agree on this input before either was refused, so this inherits
#9041's ruling rather than re-opening it.

The refusal is a one-condition widening of the same datasource-level
refinement (the one door that sees both halves at once), pathed at
`config.username`, and it names BOTH valid authoring fixes without prescribing
either. Its message is deliberately **not** #9041's: there `config.url`
supersedes the discrete `username` so the only fix is the URL's userinfo, while
here `config.username` is the live field — a refusal naming a remedy that does
not apply is worse than no refusal.

**Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'`
rows judged identically via `resolveDriverId` (the postgres arm is not widened
to — #8873 measured `pg` receiving the bound password regardless of the DSN
naming a user); "names no username" is `undefined` **or** `''`, the two
spellings that are falsy at the composer's `user ?` test and therefore drop the
secret identically (note the deliberate asymmetry with #9041's present-but-empty
userinfo carve-out: there `MongoClient` itself throws, so the shape is already
loud, while `username: ''` here connects — silently); a non-string `username` is
the driver-config gate's finding, not this one; an empty-string `credentialsRef`
is not a binding (mirrors the connect path's truthy check); a composed config
that names a user is untouched — that is the branch #8696 already works on.

Also corrected while redrawing this boundary: **an empty `config.url` is the
composed branch, not the URL branch.** `buildMongoUrl` opens `if (explicit)
return explicit;`, so `url: ''` falls through and composes from the discrete
fields — but #9041's arm judged it as a URL "naming no user" and refused it even
with a live discrete `username`, i.e. rejected at publish a datasource that
connects authenticated at runtime. Both arms now split on the factory's own
branch test, so each judges exactly the branch that will run.

## FROM → TO

```yaml
# before — parsed green; the binding was a silent no-op and the datasource
# connected anonymously with the bound secret unused
driver: mongodb
config: { database: events, host: mongo.internal }
external: { credentialsRef: sys_secret:01J9ZK4T2N }

# after (authenticated intent) — name the user; the bound secret is
# interpolated beside it into the composed URI at connect (#8696)
driver: mongodb
config: { database: events, host: mongo.internal, username: svc }
external: { credentialsRef: sys_secret:01J9ZK4T2N }

# after (anonymous intent) — drop the binding that could never land
driver: mongodb
config: { database: events, host: mongo.internal }
```

There is deliberately no automatic rewrite: the two fixes are contradictory
intents — authenticate (name the user) versus anonymous (drop the binding) —
and choosing between them requires knowing what the datasource is for.

<!-- adr-0087: registered datasource-credentialsref-mongo-composed-no-username-refused -->
Original file line numberDiff line numberDiff line change
Expand Up@@ -388,4 +388,33 @@ describe('#8696 — mongodb: a bound secret reaches the client on the DSN branch
secret: BOUND_SECRET,
})).toMatchObject({ username: 'svc', password: BOUND_SECRET });
});

it.each([
['no `username` key at all', undefined],
['an empty-string `username`', ''],
])('drops the bound secret on the composed branch with %s — the measured no-op #9147 refuses at publish', async (_label, username) => {
// The connect-path measurement the #9147 publish refusal rests on, pinned
// rather than described. `buildMongoUrl` composes the URI and the secret's
// ONLY route into it is the userinfo written beside a username
// (`const auth = user ? … : ''`); `buildMongoAuth` — the DSN branch's
// route — returns early on `!url`. So a falsy `username` leaves the bound
// secret with nowhere to go, and the datasource connects ANONYMOUSLY with
// the operator told nothing.
//
// Both spellings are pinned because both are authorable and both are
// silent: that is exactly why the refusal's fence is the falsy set rather
// than key-absence. Left unpinned, a later "improvement" that injected a
// fabricated empty username here would make the publish refusal wrong with
// nothing going red — and it is measurably the wrong direction anyway (the
// sibling pin above: `{username:''}` turns an anonymous connection that
// works into a guaranteed handshake failure).
const config: Record<string, unknown> = { host: 'db.internal', port: 27017, database: 'events' };
if (username !== undefined) config.username = username;

expect(await mongoCredentials({ name: 'composed-anon', config, secret: BOUND_SECRET }))
.toBeUndefined();
// And the composed URI itself carries no userinfo to have carried it.
expect(await mongoUrl({ name: 'composed-anon', config, secret: BOUND_SECRET }))
.toBe('mongodb://db.internal:27017/events');
});
});
129 changes: 117 additions & 12 deletions packages/spec/src/data/datasource.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -365,9 +365,11 @@ export type ExternalDatasourceSettingsParsed = z.infer<typeof ExternalDatasource
* - **"bound" mirrors the connect path exactly**: `DatasourceConnectionService`
* resolves the ref under `if (credentialsRef)` — a truthy check — so an
* empty-string ref is not a binding there and is not one here.
* - The COMPOSED branch (no `url`; discrete `host`/`username` fields) is out
* of scope by the card's own fences: with no `url` the `username` field is
* live and the factory interpolates the secret into the URI it builds.
* - The COMPOSED branch (no `url`) is judged by its own twin refusal since
* #9147 — see {@link CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED}. It is a
* separate message because the remedy differs: there the discrete
* `username` field is live, so the fix is `config.username`, not the URL's
* userinfo. #9041 fenced it out; #9147 widened the same refinement into it.
*/
const CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED =
'this mongo `config.url` names no user in its userinfo while `external.credentialsRef` binds '
Expand All@@ -384,6 +386,85 @@ const CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED =
+ 'unauthenticated, remove the `external.credentialsRef` binding. Runtime-environment DSNs '
+ '(`OS_DATABASE_URL` and friends) do not pass through this publish door and are unaffected.';

/**
* The COMPOSED-branch twin of the refusal above (#9147): `external.credentialsRef`
* bound while the mongo `config` authors no `url` AND names no `username`.
*
* Same defect, one branch over, and the branches were measured to agree on this
* input before either was refused — which is why this inherits #9041's ruling
* rather than re-opening it (the standing meta-rule: a sibling spelling of an
* already-ruled silent discard defaults into the existing refusal set).
*
* Why the pair cannot work as written, measured against
* `default-datasource-driver-factory.ts` on `origin/main` @ `b0fa4fc1a`:
*
* - with no `url`, `buildMongoUrl` COMPOSES the URI from the discrete fields,
* and the bound secret has exactly one route into it —
* `const auth = user ? \`${user}:${password}@\` : ''`. A falsy `username`
* closes that route: the composed URI is `mongodb://host:port/db`, no
* userinfo, and `spec.secret` is read into a string nothing uses;
* - the other route is shut on this branch by construction —
* `buildMongoAuth` opens with `if (!url) return undefined`, because the
* composed branch injects through the URI it builds rather than beside it.
*
* So the binding is a silent no-op: the datasource connects anonymously and the
* operator is told nothing. There is nothing to fabricate here either — a
* MongoDB handshake cannot authenticate from a password alone, which is the
* same measured asymmetry that made the URL branch's refusal the right answer
* rather than an unconditional injection.
*
* ## Why a SEPARATE message, and not #9041's
*
* The remedy differs, and a refusal naming a remedy that does not apply is
* worse than no refusal — the failure mode this module's own history section
* documents at length (the pre-#4410 `belongsInConfig` line, which sent an
* author who had made a recoverable mistake to a slot where the same mistake
* was silent again). On the URL branch `MongoConfigSchema.url` supersedes the
* discrete `username`, so the only fix is the URL's userinfo. Here `url` is
* absent and `config.username` is the live field, so `config.username` is the
* fix and userinfo is not even authorable.
*
* ## Scope fences
*
* - **mongo arm ONLY**, judged through {@link resolveDriverId} — identical to
* #9041's fence, so a stored legacy `driver: 'mongo'` row is judged the
* same. The postgres arm is NOT widened to (#8873 measured `pg` receiving
* the bound password regardless of the DSN naming a user), and neither is
* any other driver.
* - **"names no username" is `undefined` or `''`** — the two spellings that
* are falsy at `buildMongoUrl`'s `user ?` test, which is what actually
* decides whether the secret is used. `''` is included deliberately and it
* is NOT a widening past the measured no-op: `username: ''` composes the
* same userinfo-free URI and drops the same secret (measured). Excluding it
* would leave this refusal prescribing `config.username` while the platform
* still accepted the one spelling of `config.username` that keeps the
* binding silent — the prescription must land somewhere enforced. Note the
* deliberate asymmetry with #9041's fence, which DOES exclude its
* present-but-empty forms: there `MongoClient` itself throws on them
* (`URI contained empty userinfo section`), so only the `undefined` case is
* silent. Here nothing throws — `username: ''` connects, anonymously — so
* the silent set is the falsy set. Each fence follows the measurement on
* its own branch rather than the other branch's shape.
* - **A non-string `username` is the config gate's finding, not this one** —
* same posture as #9041 takes toward a non-string `url`.
* - **"bound" mirrors the connect path's truthy check**, exactly as above: an
* empty-string `credentialsRef` is not a binding.
*/
const CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED =
'this mongo `config` authors no `url` and names no `username`, while `external.credentialsRef` '
+ 'binds a secret — a pair that cannot work as written (#9147). With no `url` the connection '
+ 'URI is COMPOSED from the discrete fields, and the bound secret has exactly one route into '
+ 'it: the userinfo the composer writes beside a username. With `username` absent (or empty) '
+ 'no userinfo is written at all, so the bound secret is never used — the binding is a silent '
+ 'no-op: the datasource connects anonymously and the operator is told nothing. (There is no '
+ 'username to fabricate: a MongoDB handshake cannot authenticate from a password alone.) Two '
+ 'authoring fixes are valid, depending on what this datasource is meant to do: add `username` '
+ 'to `config` — the discrete field is live on this branch, and the bound secret is '
+ 'interpolated beside it at connect (#8696) — or, if the datasource is genuinely meant to '
+ 'connect unauthenticated, remove the `external.credentialsRef` binding. (Replacing the '
+ 'discrete fields with a `config.url` that names a user is a third valid shape; it is judged '
+ 'by the URL-branch refusal, not by this one.)';

/**
* Replay a driver-config parse onto the datasource's own issue list (#4410).
*
Expand DownExpand Up@@ -608,20 +689,44 @@ export const DatasourceSchema = lazySchema(() => strictObject(
// author's trust on a slot that cannot pay it back.
reportDriverConfigIssues(ctx, ds.driver, ds.config, ['config']);

// #9041 — see CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED. This cannot live in
// #9041 (url branch) + #9147 (composed branch) — see
// CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED and
// CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED. Neither can live in
// `MongoConfigSchema` (a config-level refinement sees only `config`;
// `credentialsRef` sits on the datasource), so it runs here, where both
// halves are visible at once. It composes independently with the config
// `credentialsRef` sits on the datasource), so both run here, where both
// halves are visible at once. They compose independently with the config
// gate above: a config also violating #8082/#8336/#9040 reports those
// issues too, each at its own path.
//
// The two arms split on the connect path's OWN branch test, not on key
// presence: `buildMongoUrl` opens `if (explicit) return explicit;`, so a
// TRUTHY `config.url` is the DSN branch and anything falsy composes from the
// discrete fields. Splitting any other way misjudges `url: ''` — before
// #9147 it took the url arm and was refused for "naming no user" even with a
// live discrete `username`, i.e. a configuration that connects
// authenticated today was rejected at publish. Each arm now judges exactly
// the branch that will run.
if (resolveDriverId(ds.driver) === 'mongodb' && ds.external?.credentialsRef) {
const url = ds.config?.['url'];
if (typeof url === 'string' && urlUserinfoUsername(url) === undefined) {
ctx.addIssue({
code: 'custom',
path: ['config', 'url'],
message: CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED,
});
if (typeof url === 'string' && url !== '') {
if (urlUserinfoUsername(url) === undefined) {
ctx.addIssue({
code: 'custom',
path: ['config', 'url'],
message: CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED,
});
}
} else if (url === undefined || url === '') {
// A non-string `url` (`42`, `null`) reaches neither arm: it has no
// branch to predict and the config gate already reports the type error.
const username = ds.config?.['username'];
if (username === undefined || username === '') {
ctx.addIssue({
code: 'custom',
path: ['config', 'username'],
message: CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED,
});
}
}
}

Expand Down
Loading
Loading