diff --git a/.changeset/datasource-credentialsref-mongo-composed-no-username-refused.md b/.changeset/datasource-credentialsref-mongo-composed-no-username-refused.md new file mode 100644 index 0000000000..62f2cbee1d --- /dev/null +++ b/.changeset/datasource-credentialsref-mongo-composed-no-username-refused.md @@ -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. + + diff --git a/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts b/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts index 88977957f4..f256c90b6b 100644 --- a/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts +++ b/packages/services/service-datasource/src/__tests__/bound-secret-dsn-branches.test.ts @@ -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 = { 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'); + }); }); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 20579baa93..fb8cdc33bc 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -365,9 +365,11 @@ export type ExternalDatasourceSettingsParsed = z.infer 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, + }); + } } } diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts index 5ff56a3dd3..7dd80e8830 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -808,17 +808,30 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 } }); - it('the COMPOSED branch (no `url`) is out of scope — discrete fields + binding stay accepted', () => { + it('the COMPOSED branch is #9147\'s arm, never this one — a composed config reports neither #9041 nor a `config.url` path', () => { // With no `url` the discrete `username` is live and the factory // interpolates the bound secret into the URI it composes (#8696's other - // branch), so there is no contradictory pair to refuse. - const result = parse({ + // branch), so a composed config that NAMES a user has no contradictory + // pair at all … + const named = parse({ name: 'events', driver: 'mongodb', config: { database: 'events', host: 'mongo.internal', username: 'svc' }, external: { ...BOUND }, }); - expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(named.success, JSON.stringify(named.error?.issues)).toBe(true); + // … and one that does not is judged by #9147's own message, with #9041's + // URL prescription (which would name a fix this branch cannot take) kept + // out. The two arms partition the input; they never both fire. + const unnamed = parse({ + name: 'events', + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal' }, + external: { ...BOUND }, + }); + expect(unnamed.success).toBe(false); + expect(unnamed.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + expect(unnamed.error!.issues.some((i) => i.path.join('.') === 'config.url')).toBe(false); }); it('an empty-string `credentialsRef` is not a binding — mirrors the connect path\'s truthy check', () => { @@ -879,6 +892,22 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 expect(result.error!.issues.some((i) => i.message.includes('#9040'))).toBe(true); }); + it('an empty `config.url` is the COMPOSED branch, not this one — a live discrete username is not refused', () => { + // The over-refusal #9147 corrected while redrawing this boundary. At + // connect `buildMongoUrl` opens `if (explicit) return explicit;`, so an + // empty `url` falls through and composes from the discrete fields — where + // `username: 'svc'` makes the bound secret live. Judging it as a "url + // naming no user" rejected, at publish, a datasource that connects + // authenticated. The arms split on truthiness, as the factory does. + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: '', database: 'events', host: 'mongo.internal', username: 'svc' }, + external: { ...BOUND }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + it('composes with the #8082 userinfo refusal the other way: a password-bearing URL has a USER', () => { // `user:password@host` violates #8082, but its userinfo NAMES a user — so // this refusal correctly stays out and the author gets exactly the #8082 @@ -894,3 +923,203 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); }); }); + +/** + * The COMPOSED-branch twin of the pair above (#9147) — `external.credentialsRef` + * bound while the mongo `config` authors no `url` and names no `username`. + * + * Same silent discard, one branch over, and the branches were measured to agree + * on this input before either was refused — so this inherits #9041's ruling + * rather than re-opening it. What does NOT carry over is the remedy: with no + * `url` the discrete `config.username` is the live field, so the fix is + * `config.username`, and #9041's "add the username to the URL's userinfo" would + * name a fix this branch cannot take. + * + * The mechanism, measured against `default-datasource-driver-factory.ts`: with + * no `url`, `buildMongoUrl` composes the URI and the bound 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 secret with nowhere to go. + * + * Envelope note (same as the #8082/#9040/#9041 pins above): the zod issue's + * `code` and its pathed location are the whole envelope at this layer — every + * schema refusal is wrapped uniformly by the publish door (metadata-protocol's + * `422 INVALID_METADATA`, whose `issues[]` carry these codes verbatim). + */ +describe('datasource — bound credentialsRef + composed mongo config naming no username refused (#9147)', () => { + const BOUND = { credentialsRef: 'sys_secret:01J9ZK4T2N' } as const; + /** The composed branch's minimum viable target — no `url`, so the URI is built. */ + const COMPOSED = { database: 'events', host: 'mongo.internal' } as const; + const parse = (ds: Record) => DatasourceSchema.safeParse(ds); + const refusalOf = (ds: Record) => { + const result = parse(ds); + if (result.success) return undefined; + return result.error.issues.find( + (i) => i.path.join('.') === 'config.username' && i.message.includes('#9147'), + ); + }; + + // ── the newly-refused conjunction ───────────────────────────────────────── + + it('refuses the pair, pathed at `config.username`, naming BOTH fixes and prescribing neither', () => { + const issue = refusalOf({ + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED }, + external: { ...BOUND }, + }); + expect(issue, 'refusal must be pathed at `config.username`').toBeDefined(); + expect(issue!.code).toBe('custom'); + // Both valid authoring fixes named, neither prescribed. + expect(issue!.message).toContain('add `username` to `config`'); + expect(issue!.message).toContain('remove the `external.credentialsRef` binding'); + // And the mechanism, so the author is told WHY the pair cannot work. + expect(issue!.message).toContain('silent no-op'); + // The remedy that does NOT apply here must not be copied in: on this branch + // there is no URL to put a userinfo in, and a refusal naming an + // inapplicable fix is worse than no refusal (the pre-#4410 + // `belongsInConfig` defect, documented in datasource.zod.ts). + expect(issue!.message).not.toContain('mongodb://user@host/db'); + expect(issue!.message).not.toContain('add the username to the URL'); + }); + + it('judges a legacy `driver: mongo` row identically (alias-resolved, like #9041 and the #9040 read path)', () => { + expect(refusalOf({ + name: 'events', + driver: 'mongo', + config: { ...COMPOSED }, + external: { ...BOUND }, + })).toBeDefined(); + }); + + it('an EMPTY-STRING `username` is refused too — it is the same silent no-op, and the prescription must land somewhere enforced', () => { + // Deliberate asymmetry with #9041's present-but-empty carve-out: there + // `MongoClient` throws on the empty userinfo forms, so the shape is + // already loud. Here nothing throws — `username: ''` is falsy at + // `buildMongoUrl`'s `user ?` test, composes the same userinfo-free URI and + // connects anonymously. Accepting it would leave this refusal prescribing + // `config.username` while the platform still accepted the one spelling of + // `config.username` that keeps the binding silent. + expect(refusalOf({ + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED, username: '' }, + external: { ...BOUND }, + })).toBeDefined(); + }); + + it('an empty `config.url` routes HERE, not to #9041 — the arms split on the factory\'s own branch test', () => { + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: '', ...COMPOSED }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + }); + + // ── the fence: each single condition absent is still ACCEPTED ───────────── + + it('near-miss ① `url` present (naming a user) — the discrete `username` is superseded, nothing to refuse', () => { + const ds = { + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://app@db.internal:27017/app' }, + external: { ...BOUND }, + }; + const result = parse(ds); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + // Byte-identical across parses — this refusal changed nothing on this path. + expect(DatasourceSchema.parse(ds)).toEqual(result.data); + }); + + it('near-miss ① `url` present naming NO user — exactly ONE refusal fires, and it is #9041\'s', () => { + // The arms partition the input: the author must never receive two messages + // prescribing different fixes for one datasource. + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://db.internal:27017/app' }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(false); + }); + + it('near-miss ② a discrete `username` present — the branch where the bound secret is LIVE (#8696)', () => { + const ds = { + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED, username: 'svc' }, + external: { ...BOUND }, + }; + const result = parse(ds); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.data!.config).toEqual(ds.config); + expect(DatasourceSchema.parse(ds)).toEqual(result.data); + }); + + it('near-miss ③ no binding — a composed anonymous datasource is a legal intent', () => { + const ds = { name: 'events', driver: 'mongodb', config: { ...COMPOSED } }; + const result = parse(ds); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(DatasourceSchema.parse(ds)).toEqual(result.data); + }); + + it('near-miss ③ an empty-string `credentialsRef` is not a binding — mirrors the connect path\'s truthy check', () => { + // `DatasourceConnectionService` resolves the ref under `if (credentialsRef)`, + // so an empty ref binds nothing there and is not a binding here either. + expect(refusalOf({ + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED }, + external: { credentialsRef: '' }, + })).toBeUndefined(); + }); + + it('fence — the postgres arm is NOT widened to: a composed pg config with no username + binding stays accepted', () => { + // #8873 measured `pg` receiving the bound password regardless of the DSN + // naming a user, so the mongo mechanism does not transfer to it on this + // branch any more than it did on the URL branch. + const result = parse({ + name: 'warehouse', + driver: 'postgres', + schemaMode: 'external', + config: { database: 'analytics', host: 'wh.internal' }, + external: { ...BOUND, allowWrites: false }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('a non-string `username` is the config gate\'s finding, not this one', () => { + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED, username: 42 }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + // The driver-config parse reports the type error at the same path; this + // refusal stays silent rather than judging a value with no branch to predict. + expect(result.error!.issues.some((i) => i.path.join('.') === 'config.username')).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(false); + }); + + it('composes with the #9040 passthrough refusal — one artefact, both findings, own paths', () => { + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { ...COMPOSED, options: { auth: { username: 'app', password: 'hunter2' } } }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('config.username'); + expect(paths).toContain('config.options.auth.password'); + expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9040'))).toBe(true); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts new file mode 100644 index 0000000000..ee1841c2a2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The COMPOSED-branch twin of `datasource-credentialsref-mongo-url-no-user-refused` +// (#9147 widening #9041's refinement). Same silent discard, one branch over, and a +// DIFFERENT remedy — which is why it is its own entry rather than a widened surface +// on that one: with no `url` the discrete `config.username` is the live field, so the +// fix is `config.username`, not the URL's userinfo. +export const entry: SemanticMigration = { + id: 'datasource-credentialsref-mongo-composed-no-username-refused', + surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config` authors no ' + + '`url` and names no `username`', + replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + + 'add `username` to `config` so the bound secret is interpolated beside it into the ' + + 'composed connection URI at connect (#8696) — or, for a datasource genuinely meant to ' + + 'connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the ' + + 'orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` ' + + 'that names a user is a third valid shape, judged by the sibling #9041 prescription.', + reason: + 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + + 'every door it passed. 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 the 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 `credentialsRef` bound with no `url` and no ' + + '`username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the ' + + 'operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot ' + + 'authenticate from a password alone — the same measured asymmetry behind the sibling ' + + 'URL-branch refusal. Both branches had always agreed on this input, so this inherits that ' + + 'ruling rather than re-opening it, and lands at the same authoring/publish door — the one ' + + 'place both halves are visible at once — as the "absence must be loud" half of the ' + + '#7314/#7385/#8152/#8875/#8696 family. Deliberately NOT refused, each measured: a ' + + 'discrete `username` that is present and non-empty (the secret is live there — that is ' + + 'the branch #8696 already works on), an empty-string `credentialsRef` (not a binding — ' + + 'the connect path resolves under a truthy check), a non-string `username` (the driver ' + + 'config gate already reports the type error), and every other driver arm (the postgres ' + + 'equivalent is re-judged after #8873, never inherited — `pg` receives the bound password ' + + 'regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + + 'sibling entry\'s present-but-empty userinfo carve-out: there MongoClient itself throws ' + + '(`URI contained empty userinfo section`) so the shape is already loud, while here ' + + '`username: \'\'` composes the same userinfo-free URI and connects — silently. There is ' + + 'no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate ' + + '(name the user) versus anonymous (drop the binding) — and choosing between them requires ' + + 'knowing what the datasource is for.', + acceptanceCriteria: + 'Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` ' + + 'names a non-empty `config.username` and connects authenticated as that user; every ' + + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse ' + + 'reports the #9147 refusal.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fb4b754260..e269e62b59 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5169,6 +5169,54 @@ const step18: MigrationStep = { 'the connection form) and still connects; no URL-embedded credential remains in any ' + 'stored `sys_metadata` row or authored source.', }, + // The COMPOSED-branch twin of `datasource-credentialsref-mongo-url-no-user-refused` + // (#9147 widening #9041's refinement). Same silent discard, one branch over, and a + // DIFFERENT remedy — which is why it is its own entry rather than a widened surface + // on that one: with no `url` the discrete `config.username` is the live field, so the + // fix is `config.username`, not the URL's userinfo. + { + id: 'datasource-credentialsref-mongo-composed-no-username-refused', + surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config` authors no ' + + '`url` and names no `username`', + replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + + 'add `username` to `config` so the bound secret is interpolated beside it into the ' + + 'composed connection URI at connect (#8696) — or, for a datasource genuinely meant to ' + + 'connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the ' + + 'orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` ' + + 'that names a user is a third valid shape, judged by the sibling #9041 prescription.', + reason: + 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + + 'every door it passed. 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 the 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 `credentialsRef` bound with no `url` and no ' + + '`username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the ' + + 'operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot ' + + 'authenticate from a password alone — the same measured asymmetry behind the sibling ' + + 'URL-branch refusal. Both branches had always agreed on this input, so this inherits that ' + + 'ruling rather than re-opening it, and lands at the same authoring/publish door — the one ' + + 'place both halves are visible at once — as the "absence must be loud" half of the ' + + '#7314/#7385/#8152/#8875/#8696 family. Deliberately NOT refused, each measured: a ' + + 'discrete `username` that is present and non-empty (the secret is live there — that is ' + + 'the branch #8696 already works on), an empty-string `credentialsRef` (not a binding — ' + + 'the connect path resolves under a truthy check), a non-string `username` (the driver ' + + 'config gate already reports the type error), and every other driver arm (the postgres ' + + 'equivalent is re-judged after #8873, never inherited — `pg` receives the bound password ' + + 'regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + + 'sibling entry\'s present-but-empty userinfo carve-out: there MongoClient itself throws ' + + '(`URI contained empty userinfo section`) so the shape is already loud, while here ' + + '`username: \'\'` composes the same userinfo-free URI and connects — silently. There is ' + + 'no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate ' + + '(name the user) versus anonymous (drop the binding) — and choosing between them requires ' + + 'knowing what the datasource is for.', + acceptanceCriteria: + 'Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` ' + + 'names a non-empty `config.username` and connects authenticated as that user; every ' + + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse ' + + 'reports the #9147 refusal.', + }, { id: 'datasource-credentialsref-mongo-url-no-user-refused', surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config.url` names ' +