diff --git a/.changeset/datasource-credentialsref-mongo-url-no-user-refused.md b/.changeset/datasource-credentialsref-mongo-url-no-user-refused.md new file mode 100644 index 0000000000..f67a7c5dec --- /dev/null +++ b/.changeset/datasource-credentialsref-mongo-url-no-user-refused.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a mongo `config.url` naming no user" at publish (#9041) + +**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 +and #9040; the migration prescription is registered under protocol major 18, +where `os migrate meta` users will look). + +The "absence must be loud" half of the #8696 family, previously unserved: +after #8696 a mongo datasource that binds `external.credentialsRef` and +authors a `config.url` gets the secret injected as MongoClient `auth` — but +`auth` needs a username as well as a password, and with `url` present the only +place the username can come from is the URL's own userinfo. So the injection +is conditional on the URL naming a user: + +- `mongodb://app@db.internal:27017/app` + bound secret → injected, correct; +- `mongodb://db.internal:27017/app` + bound secret → **nothing happens** — the + datasource connects anonymously and the operator is told nothing. + +The second shape is a configuration that cannot work as written; it is now +refused at the datasource level (`DatasourceSchema`'s refinement — the one +door that sees both halves at once; a config-level refinement cannot, because +`credentialsRef` sits on the datasource and `url` inside `config`). The +refusal names BOTH valid authoring fixes without prescribing either: add the +username to the URL, or drop the binding. + +**Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'` +rows judged identically via `resolveDriverId` (the postgres arm injects on a +user-less DSN by its own measured mechanism, #8873, and is not assumed to +share the defect); "names no user" means `urlUserinfoUsername` answers +`undefined` — the present-but-empty userinfo forms already throw in +MongoClient itself (`MongoParseError: URI contained empty userinfo section`); +an empty-string `credentialsRef` is not a binding (mirrors the connect path's +truthy check); the composed branch (no `url`) is untouched — its discrete +`username` field is live. Injecting a fabricated empty username instead of +refusing was measured worse on mongodb@7.5.0: it turns a connection that works +anonymously today into a guaranteed handshake failure. Composes independently +with the sibling refusals (#8082 userinfo, #8336 placeholders, #9040 options +passthrough) — one artefact violating several reports each at its own path. + +## 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: + url: mongodb://mongo.internal:27017/events +external: + credentialsRef: sys_secret:01J9ZK4T2N + +# after (authenticated intent) — name the user in the URL; the bound secret +# is injected at connect (#8696) +driver: mongodb +config: + url: mongodb://app@mongo.internal:27017/events +external: + credentialsRef: sys_secret:01J9ZK4T2N + +# after (anonymous intent) — drop the binding that could never land +driver: mongodb +config: + url: mongodb://mongo.internal:27017/events +``` + +There is deliberately no automatic rewrite: the two fixes are contradictory +intents — authenticate (add the username) versus anonymous (drop the binding) +— and choosing between them requires knowing what the datasource is for. + + diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 6734ff19ce..20579baa93 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -9,7 +9,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { validateDriverConfig } from './driver/config-registry.zod'; +import { urlUserinfoUsername } from './driver/common.zod'; +import { resolveDriverId, validateDriverConfig } from './driver/config-registry.zod'; /* * ── Unknown-key strictness (#4001 data step, closed out by #4410) ─────────── @@ -324,6 +325,65 @@ export type ExternalDatasourceSettings = z.input; +/** + * Refusal for the contradictory pair "`external.credentialsRef` bound + a + * mongo `config.url` whose userinfo names NO user" (#9041) — the "absence must + * be loud" half of the #8696 family, refused at the one door that sees both + * halves at once. + * + * Why the pair cannot work as written, all measured (on the #9041 card and + * re-verified against `buildMongoAuth` in service-datasource's driver + * factory): `MongoClient` credentials need a username as well as a password, + * and with `url` present the discrete `username` field is ignored + * (`MongoConfigSchema.url` supersedes it), so the only place the username can + * come from is the URL's own userinfo. The bound secret is therefore injected + * only when the URL names a user; on a user-less URL the binding is a silent + * no-op — the datasource connects anonymously and the operator is told + * nothing. Injecting anyway is worse, not better: measured on mongodb@7.5.0, + * a user-less URL carries NO credentials at all, while the same URL with + * `auth: { username: '', password: BOUND }` carries `credentials{username:''}` + * — fabricating an empty username converts a datasource that connects today + * into a guaranteed handshake failure. And refusing at CONNECT would + * contradict `MongoConfigSchema.url`'s published contract ("bind the secret … + * and it is injected at connect time") while planting a per-branch asymmetry + * inside the factory — the defect class #8696 closed. Hence this door. + * + * Scope fences, each deliberate (#9041's triage, adopted verbatim): + * + * - **mongo arm ONLY** (judged through {@link resolveDriverId}, so a stored + * legacy `driver: 'mongo'` row is judged identically to `'mongodb'` — the + * same alias mechanism the #9040 read-path redaction uses). The postgres + * arm injects on a user-less DSN by a different, measured mechanism + * (#8873: `pg` sends a password only when the server asks) and is NOT + * assumed to share this defect. + * - **"names no user" means {@link urlUserinfoUsername} answers + * `undefined`** — no userinfo at all. The present-but-empty forms + * (`mongodb://@h/db`, `mongodb://:p@h/db` — the accessor answers `''`) + * already throw in `MongoClient` itself (`MongoParseError: URI contained + * empty userinfo section`, measured), so only the `undefined` case is + * silent and only it is refused here. + * - **"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. + */ +const CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED = + 'this mongo `config.url` names no user in its userinfo while `external.credentialsRef` binds ' + + 'a secret — a pair that cannot work as written (#9041). MongoClient credentials need a ' + + 'username as well as a password, and with `url` present the only place the username can ' + + 'come from is the URL\'s own userinfo (the discrete `username` field is superseded by ' + + '`url`), so the bound secret is injected only when the URL names a user — on this URL the ' + + 'binding is a silent no-op: the datasource connects anonymously and the secret is never ' + + 'used. (Injecting with a fabricated empty username is worse: measured on mongodb@7.5.0, it ' + + 'turns a connection that works anonymously today into a guaranteed handshake failure.) Two ' + + 'authoring fixes are valid, depending on what this datasource is meant to do: add the ' + + 'username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret is ' + + 'injected at connect (#8696) — or, if the datasource is genuinely meant to connect ' + + 'unauthenticated, remove the `external.credentialsRef` binding. Runtime-environment DSNs ' + + '(`OS_DATABASE_URL` and friends) do not pass through this publish door and are unaffected.'; + /** * Replay a driver-config parse onto the datasource's own issue list (#4410). * @@ -548,6 +608,23 @@ 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 + // `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 + // gate above: a config also violating #8082/#8336/#9040 reports those + // issues too, each at its own path. + 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 (ds.schemaMode !== 'managed' && !ds.external) { ctx.addIssue({ code: 'custom', 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 e8aabf38b2..5ff56a3dd3 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -699,3 +699,198 @@ describe('mongo options passthrough — credential refusal (#9040)', () => { } }); }); + +/** + * The contradictory pair "`external.credentialsRef` bound + a mongo + * `config.url` naming no user" is refused at the datasource level (#9041) — + * the "absence must be loud" half of the #8696 family. The binding is a silent + * no-op at connect (`buildMongoAuth` injects only when the URL's userinfo + * names a user, because `MongoClient` credentials need a username the URL must + * supply and fabricating an empty one is a measured handshake failure), so the + * config cannot work as written and the authoring door — the one place both + * halves are visible at once — says so. + * + * Envelope note (same as the #8082/#9040 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 + user-less mongo url refused (#9041)', () => { + const BOUND = { credentialsRef: 'sys_secret:01J9ZK4T2N' } 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.url' && i.message.includes('#9041'), + ); + }; + + it('refuses the pair, pathed at `config.url`, naming BOTH fixes and prescribing neither', () => { + const issue = refusalOf({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://db.internal:27017/app' }, + external: BOUND, + }); + expect(issue, 'refusal must be pathed at `config.url`').toBeDefined(); + expect(issue!.code).toBe('custom'); + // Fence ③ — both valid authoring fixes are named, neither prescribed: + // add the username to the URL, or drop the binding. + expect(issue!.message).toContain('add the username'); + expect(issue!.message).toContain('mongodb://user@host/db'); + 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'); + }); + + it('judges a legacy `driver: mongo` row identically (alias-resolved, like the #9040 read path)', () => { + const issue = refusalOf({ + name: 'events', + driver: 'mongo', + config: { url: 'mongodb://db.internal:27017/app' }, + external: BOUND, + }); + expect(issue).toBeDefined(); + }); + + it('a `mongodb+srv://` URL naming no user is the same silent no-op — refused', () => { + const issue = refusalOf({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb+srv://c0.example.net/app' }, + external: BOUND, + }); + expect(issue).toBeDefined(); + }); + + it('accepts the blessed shape byte-identically: bare-username URL + bound secret (#8155)', () => { + 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); + expect(result.data!.config).toEqual(ds.config); + expect(result.data!.external!.credentialsRef).toBe(BOUND.credentialsRef); + // Multi-host too — the form `new URL()` cannot even parse (#8696). + const multi = parse({ + ...ds, + config: { url: 'mongodb://app@h1:27017,h2:27017/app' }, + }); + expect(multi.success, JSON.stringify(multi.error?.issues)).toBe(true); + }); + + it('accepts a user-less URL with NO binding — anonymous connect is a legal intent', () => { + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://db.internal:27017/app' }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('fence ② — present-but-EMPTY userinfo is NOT this refusal (the client refuses those itself)', () => { + // `urlUserinfoUsername` answers `''` (userinfo present), not `undefined`: + // `MongoClient` throws `MongoParseError: URI contained empty userinfo + // section` on these forms with or without a bound secret (measured on the + // card), so the silent-no-op refusal deliberately does not claim them. + for (const url of ['mongodb://@db.internal:27017/app', 'mongodb://:p@db.internal:27017/app']) { + expect(urlUserinfoUsername(url)).toBe(''); + expect(refusalOf({ + name: 'events', + driver: 'mongodb', + config: { url }, + external: { ...BOUND }, + })).toBeUndefined(); + } + }); + + it('the COMPOSED branch (no `url`) is out of scope — discrete fields + binding stay accepted', () => { + // 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({ + name: 'events', + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal', username: 'svc' }, + external: { ...BOUND }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('an empty-string `credentialsRef` is not a binding — mirrors the connect path\'s truthy check', () => { + expect(refusalOf({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://db.internal:27017/app' }, + external: { credentialsRef: '' }, + })).toBeUndefined(); + }); + + it('fence ① — the postgres arm is NOT assumed: a user-less pg DSN + binding stays accepted', () => { + // #8873 measured pg injecting on a user-less DSN (`pg` sends a password + // only when the server asks), so the mongo mechanism does not transfer; + // the postgres equivalent is re-judged after #8873, never inherited. + const result = parse({ + name: 'warehouse', + driver: 'postgres', + config: { url: 'postgresql://db.internal:5432/analytics' }, + schemaMode: 'external', + external: { ...BOUND }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('a non-string `url` is the config gate\'s finding, not this one', () => { + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: 42 }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + // The driver-config parse reports the type error at the same path; the + // #9041 refusal stays silent rather than judging a value that has no + // userinfo to read. + expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + }); + + it('composes with the #9040 passthrough refusal — one artefact, both findings, own paths', () => { + // The PM-mechanism composition pin: the datasource-level #9041 refinement + // and the config-level #9040 `credentialFreeMongoOptions` judge the same + // artefact independently — an input violating both reports both. + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { + url: 'mongodb://db.internal:27017/app', + 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.url'); + expect(paths).toContain('config.options.auth.password'); + expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9040'))).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 + // prescription (bind the secret), not a contradictory second message. + const result = parse({ + name: 'events', + driver: 'mongodb', + config: { url: 'mongodb://app:hunter2@db.internal:27017/app' }, + external: { ...BOUND }, + }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes('#8082'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts new file mode 100644 index 0000000000..80f02cfc69 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-credentialsref-mongo-url-no-user-refused', + surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config.url` names ' + + 'no user in its userinfo', + replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + + 'add the username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret ' + + 'is injected 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)', + reason: + 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + + 'every door it passed. MongoClient credentials need a username as well as a password, ' + + 'and with `url` present the discrete `username` field is superseded — the only place ' + + 'the username can come from is the URL\'s own userinfo. So the #8696 injection is ' + + 'conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + + 'authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the ' + + 'secret unused and the operator told nothing. Injecting anyway was measured worse ' + + '(mongodb@7.5.0): fabricating an empty username turns a connection that works ' + + 'anonymously today into a guaranteed handshake failure, and refusing at connect would ' + + 'contradict `MongoConfigSchema.url`\'s published contract ("bind the secret … and it is ' + + 'injected at connect time") while planting a per-branch asymmetry inside the driver ' + + 'factory — the defect class #8696 closed. The refusal therefore lands at the ' + + '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: the present-but-empty userinfo forms (`mongodb://@h/db`, ' + + '`mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty ' + + 'userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path ' + + 'resolves under a truthy check), the composed branch (no `url`, where the discrete ' + + '`username` is live), and every other driver arm (the postgres equivalent is re-judged ' + + 'after #8873, never inherited — `pg` injects on a user-less DSN by its own measured ' + + 'mechanism). There is no mechanical rewrite because the two valid fixes are ' + + 'CONTRADICTORY intents — authenticate (add the username) 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 `config.url` ' + + 'has a username in that URL\'s userinfo and connects authenticated as that user; every ' + + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + + 'parse reports the #9041 refusal.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8457b46015..2d228076ef 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5069,6 +5069,45 @@ const step18: MigrationStep = { 'the connection form) and still connects; no URL-embedded credential remains in any ' + 'stored `sys_metadata` row or authored source.', }, + { + id: 'datasource-credentialsref-mongo-url-no-user-refused', + surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config.url` names ' + + 'no user in its userinfo', + replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + + 'add the username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret ' + + 'is injected 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)', + reason: + 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + + 'every door it passed. MongoClient credentials need a username as well as a password, ' + + 'and with `url` present the discrete `username` field is superseded — the only place ' + + 'the username can come from is the URL\'s own userinfo. So the #8696 injection is ' + + 'conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + + 'authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the ' + + 'secret unused and the operator told nothing. Injecting anyway was measured worse ' + + '(mongodb@7.5.0): fabricating an empty username turns a connection that works ' + + 'anonymously today into a guaranteed handshake failure, and refusing at connect would ' + + 'contradict `MongoConfigSchema.url`\'s published contract ("bind the secret … and it is ' + + 'injected at connect time") while planting a per-branch asymmetry inside the driver ' + + 'factory — the defect class #8696 closed. The refusal therefore lands at the ' + + '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: the present-but-empty userinfo forms (`mongodb://@h/db`, ' + + '`mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty ' + + 'userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path ' + + 'resolves under a truthy check), the composed branch (no `url`, where the discrete ' + + '`username` is live), and every other driver arm (the postgres equivalent is re-judged ' + + 'after #8873, never inherited — `pg` injects on a user-less DSN by its own measured ' + + 'mechanism). There is no mechanical rewrite because the two valid fixes are ' + + 'CONTRADICTORY intents — authenticate (add the username) 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 `config.url` ' + + 'has a username in that URL\'s userinfo and connects authenticated as that user; every ' + + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + + 'parse reports the #9041 refusal.', + }, { id: 'driver-sql-unresolvable-where-column-refused', surface: