From 3126f655aaca00b7efb12accdc4d40921486bf5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:20:20 +0000 Subject: [PATCH 1/2] feat(spec): refuse a credential in the mongo options passthrough (config.options.auth.password) at publish (#9040) Write door: closed measured list (MONGO_OPTIONS_CREDENTIAL_PATHS) behind credentialFreeMongoOptions, composed with placeholderFreeDeep on MongoConfigSchema.options; non-empty string auth.password refused with the binder prescription (bound secret measured outranking the passthrough at connect, #8696). Read door: passthrough secret paths (auth.password, proxyPassword, TLS key material, AWS_SESSION_TOKEN) redacted with dotted redactedKeys; restoreRedactedConfig mirrors per leaf; the credential-migration planner refuses stored passthrough-credential rows with the per-row remedy. ADR-0087 semantic entry (registry regen to follow). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq --- .../datasource-config-redaction.test.ts | 81 ++++++++++++ .../datasource-credential-migration.test.ts | 67 ++++++++++ .../src/datasource-config-redaction.ts | 52 +++++++- .../src/datasource-credential-migration.ts | 37 ++++++ .../datasource-credential-redaction.test.ts | 100 +++++++++++++++ .../data/datasource-credential-redaction.ts | 118 +++++++++++++++++- packages/spec/src/data/driver/common.zod.ts | 107 ++++++++++++++++ .../driver/driver-credential-refusal.test.ts | 105 ++++++++++++++++ packages/spec/src/data/driver/mongo.zod.ts | 16 ++- ...config-mongo-options-credential-refused.ts | 42 +++++++ 10 files changed, 719 insertions(+), 6 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts diff --git a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts index e31af31157..34f2bacfd2 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts @@ -394,3 +394,84 @@ describe('GREEN ON MAIN — #8078 is not weakened by anything above', () => { .toEqual({ known: true, issues: [] }); }); }); + +describe('#9040 — the passthrough spelling, both halves at the service door', () => { + /** A legacy mongo row written before #9040: the password rides the MongoClient passthrough. */ + const LEGACY_MONGO: StoredDatasource = { + name: 'legacy_mongo', + driver: 'mongodb', + origin: 'runtime', + config: { + url: 'mongodb://app@mongo.internal:27017/events', + options: { + replicaSet: 'rs0', + connectTimeoutMS: 5000, + auth: { username: 'app', password: 'PLAINTEXT-IN-METADATA' }, + }, + }, + }; + + it('read path: getDatasource() serves the passthrough without its password, and says so', async () => { + const { service } = makeService([LEGACY_MONGO]); + const read = await service.getDatasource('legacy_mongo'); + expect(read!.config!.options).toEqual({ + replicaSet: 'rs0', + connectTimeoutMS: 5000, + auth: { username: 'app' }, + }); + expect(read!.redactedConfigKeys).toContain('options.auth.password'); + }); + + it('an untouched round-trip keeps the stored passthrough credential', async () => { + const { service, records } = makeService([LEGACY_MONGO]); + const read = await service.getDatasource('legacy_mongo'); + await service.updateDatasource('legacy_mongo', { config: read!.config, label: 'Renamed' }); + expect(records[0].label).toBe('Renamed'); + expect((records[0].config!.options as any).auth).toEqual({ + username: 'app', + password: 'PLAINTEXT-IN-METADATA', + }); + }); + + it('editing a SIBLING passthrough option still restores the untouched leaf', async () => { + const { service, records } = makeService([LEGACY_MONGO]); + const read = await service.getDatasource('legacy_mongo'); + const options = { ...(read!.config!.options as Record), replicaSet: 'rs1' }; + await service.updateDatasource('legacy_mongo', { config: { ...read!.config, options } }); + expect((records[0].config!.options as any).replicaSet).toBe('rs1'); + expect((records[0].config!.options as any).auth.password).toBe('PLAINTEXT-IN-METADATA'); + }); + + it('an author who deletes the `auth` block WINS — a removed container is never re-grafted', async () => { + const { service, records } = makeService([LEGACY_MONGO]); + const read = await service.getDatasource('legacy_mongo'); + const { auth: _auth, ...options } = read!.config!.options as Record; + await service.updateDatasource('legacy_mongo', { config: { ...read!.config, options } }); + expect(records[0].config!.options).not.toHaveProperty('auth'); + }); + + it('the restore never aliases a mutation back into the caller patch object', () => { + const stored = { + options: { auth: { username: 'app', password: 'hunter2' }, replicaSet: 'rs0' }, + }; + const patch = { options: { auth: { username: 'app' }, replicaSet: 'rs0' } }; + const patchOptionsBefore = patch.options; + const restored = restoreRedactedConfig('mongodb', patch, stored)!; + expect((restored.options as any).auth.password).toBe('hunter2'); + // The caller's own objects are untouched — the graft copied the spine. + expect(patch.options).toBe(patchOptionsBefore); + expect((patch.options as any).auth).not.toHaveProperty('password'); + }); + + it('the write gate still refuses a TYPED-IN passthrough password on its own merits', async () => { + const { service } = makeService([LEGACY_MONGO]); + await expect( + service.updateDatasource('legacy_mongo', { + config: { + url: 'mongodb://app@mongo.internal:27017/events', + options: { auth: { username: 'app', password: 'typed-new-secret' } }, + }, + }), + ).rejects.toThrow(/options\.auth\.password/); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts index 4ec3a262be..be0e01524d 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts @@ -222,3 +222,70 @@ describe('urlCredentialKeys', () => { expect((plan as { remedy: string }).remedy).toContain('secret field'); }); }); + +describe('#9040 — the passthrough spelling at the planner door', () => { + const mongoRow = (config: Record): StoredDatasource => ({ + name: 'events', + driver: 'mongodb', + origin: 'runtime', + config, + }); + + it('refuses a stored `options.auth.password` row with the per-row remedy', () => { + const plan = planCredentialMigration( + mongoRow({ + url: 'mongodb://app@mongo.internal:27017/events', + options: { replicaSet: 'rs0', auth: { username: 'app', password: 'hunter2' } }, + }), + ); + expect(plan.action).toBe('refuse'); + if (plan.action !== 'refuse') throw new Error('unreachable'); + expect(plan.reason).toContain('config.options.auth.password'); + expect(plan.remedy).toContain('secret field'); + expect(plan.remedy).toContain('`auth` block'); + }); + + it('refuses the passthrough row even when a discrete key could be bound — whole-row, like the URL rule', () => { + const plan = planCredentialMigration( + mongoRow({ + host: 'mongo.internal', + database: 'events', + password: 'hunter2', + options: { auth: { username: 'app', password: 'hunter2' } }, + }), + ); + expect(plan.action).toBe('refuse'); + if (plan.action !== 'refuse') throw new Error('unreachable'); + expect(plan.reason).toContain('config.options.auth.password'); + }); + + it('a benign passthrough is not a credential — the row stays bindable / clean', () => { + const clean = planCredentialMigration( + mongoRow({ + url: 'mongodb://app@mongo.internal:27017/events', + options: { replicaSet: 'rs0', tls: true, auth: { username: 'app' } }, + }), + ); + expect(clean).toEqual({ action: 'none', status: 'nothing-to-migrate', remaining: [] }); + + const bindable = planCredentialMigration( + mongoRow({ + host: 'mongo.internal', + database: 'events', + password: 'hunter2', + options: { replicaSet: 'rs0' }, + }), + ); + expect(bindable.action).toBe('bind'); + }); + + it('an empty passthrough password carries no secret — same asymmetry as `user:@host`', () => { + const plan = planCredentialMigration( + mongoRow({ + url: 'mongodb://app@mongo.internal:27017/events', + options: { auth: { username: 'app', password: '' } }, + }), + ); + expect(plan).toEqual({ action: 'none', status: 'nothing-to-migrate', remaining: [] }); + }); +}); diff --git a/packages/services/service-datasource/src/datasource-config-redaction.ts b/packages/services/service-datasource/src/datasource-config-redaction.ts index 9ea69d3892..66e39265a7 100644 --- a/packages/services/service-datasource/src/datasource-config-redaction.ts +++ b/packages/services/service-datasource/src/datasource-config-redaction.ts @@ -31,10 +31,15 @@ * #8154's, deliberately not built here. */ -import { redactableConfigKeys, redactUrlCredentials } from '@objectstack/spec/data'; +import { + passthroughSecretPaths, + redactableConfigKeys, + redactUrlCredentials, +} from '@objectstack/spec/data'; export { refusedCredentialKeys, + passthroughSecretPaths, redactableConfigKeys, redactUrlPassword, redactUrlCredentialQueryParams, @@ -85,5 +90,50 @@ export function restoreRedactedConfig( if (out[key] === redactedStored) out[key] = storedValue; } + // The passthrough spellings (#9040) — the nested material the read path + // drops by PATH (`options.auth.password`, `options.proxyPassword`, …). The + // same narrow rule as the top-level keys, translated per leaf: restore ONLY + // when the patch's container for the leaf exists but does not speak to the + // leaf at all — exactly what the read path served. A patch carrying the leaf + // is the author's word (a typed-in `auth.password` is then refused by the + // #9040 write gate on its own merits); a patch with the CONTAINER removed is + // the author's word too (they deleted the block), so nothing is grafted. + for (const path of passthroughSecretPaths(driver)) { + const storedLeaf = valueAt(stored, path); + if (storedLeaf === undefined) continue; + const parentPath = path.slice(0, -1); + const leafKey = path[path.length - 1] as string; + const patchParent = valueAt(out, parentPath); + if (!patchParent || typeof patchParent !== 'object' || Array.isArray(patchParent)) continue; + if (leafKey in (patchParent as Record)) continue; + graftAt(out, path, storedLeaf); + } + return out; } + +/** The value at `path` inside a record-ish value, or `undefined` off the walk. */ +function valueAt(value: unknown, path: readonly string[]): unknown { + let node: unknown = value; + for (const segment of path) { + if (!node || typeof node !== 'object' || Array.isArray(node)) return undefined; + node = (node as Record)[segment]; + } + return node; +} + +/** + * Set `path` to `value` inside `out`, copying every container along the spine + * so the caller's `{ ...patch }` shallow copy never aliases a mutation back + * into the patch object the caller handed us. Every intermediate container is + * known to exist and be a record — the caller checked before grafting. + */ +function graftAt(out: Record, path: readonly string[], value: unknown): void { + let node = out; + for (const segment of path.slice(0, -1)) { + const child = { ...(node[segment] as Record) }; + node[segment] = child; + node = child; + } + node[path[path.length - 1] as string] = value; +} diff --git a/packages/services/service-datasource/src/datasource-credential-migration.ts b/packages/services/service-datasource/src/datasource-credential-migration.ts index 27ba3ad9d3..505a95835d 100644 --- a/packages/services/service-datasource/src/datasource-credential-migration.ts +++ b/packages/services/service-datasource/src/datasource-credential-migration.ts @@ -74,6 +74,7 @@ import { redactableConfigKeys, redactUrlCredentials, refusedCredentialKeys, + refusedPassthroughSecretPaths, validateDriverConfig, } from '@objectstack/spec/data'; import type { StoredDatasource } from './datasource-admin-service.js'; @@ -170,6 +171,42 @@ export function planCredentialMigration(record: StoredDatasource): CredentialMig } const config = record.config; + + // The passthrough spelling (#9040): a stored `options.auth.password` (or a + // legacy row's equivalent) is a LIVE login credential — measured, the client + // resolves the block into `MongoCredentials` — that this action cannot + // re-home mechanically: dropping the nested leaf would leave an `auth` block + // with only a username, which the client refuses at construction + // (`credentials must be an object with 'username' and 'password' + // properties`, measured on mongodb@7.5.0), and the DSN branch injects a + // bound secret only through a URL that already names a user (#8696). Refused + // with the per-row remedy, exactly like the URL spellings below. + const passthroughKeys = refusedPassthroughSecretPaths(record.driver) + .filter((path) => { + let node: unknown = config; + for (const segment of path) { + if (!node || typeof node !== 'object' || Array.isArray(node)) return false; + node = (node as Record)[segment]; + } + return typeof node === 'string' && node !== ''; + }) + .map((path) => path.join('.')); + if (passthroughKeys.length > 0) { + return { + action: 'refuse', + reason: + `Datasource '${record.name}' carries its credential inside the driver-options passthrough ` + + `(${passthroughKeys.map((k) => `config.${k}`).join(', ')}). Re-homing it here could break the ` + + 'connection: removing only the nested password leaves an `auth` block the MongoDB client ' + + 'refuses outright, and the bound secret reaches a DSN connection only through a URL that ' + + 'already names a user.', + remedy: + 'Edit the datasource in Setup → Datasources: remove the `auth` block from `options` and ' + + "enter the password in the connection form's secret field, which binds it into the secret " + + 'store (keep the username in the URL, e.g. `mongodb://user@host/db`).', + }; + } + const urlKeys = urlCredentialKeys(config); if (urlKeys.length > 0) { return { diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index 3f821867d3..694fcfb614 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -35,12 +35,14 @@ import { urlUserinfoUsername, } from './driver/index'; import { + passthroughSecretPaths, redactDatasourceConfig, redactUrlCredentialQueryParams, redactUrlCredentials, redactUrlPassword, redactableConfigKeys, refusedCredentialKeys, + refusedPassthroughSecretPaths, } from './datasource-credential-redaction'; /** The pre-#8078 alias spellings — the hand-written half of the definition. */ @@ -268,3 +270,101 @@ describe('write-door alignment, query half: redactUrlCredentials removes exactly expect(redactedKeys).toEqual(['url']); }); }); + +describe('passthrough secret redaction (#9040) — the nested spellings the key-name scrub cannot see', () => { + const STORED = { + url: 'mongodb://app@mongo.internal:27017/events', + options: { + replicaSet: 'rs0', + tls: true, + connectTimeoutMS: 5000, + auth: { username: 'app', password: 'PLAINTEXT-IN-METADATA' }, + }, + }; + + it('drops `options.auth.password`, keeps the username and every benign option, names the dotted path', () => { + const { config, redactedKeys } = redactDatasourceConfig('mongodb', STORED); + expect(config).toEqual({ + url: 'mongodb://app@mongo.internal:27017/events', + options: { replicaSet: 'rs0', tls: true, connectTimeoutMS: 5000, auth: { username: 'app' } }, + }); + expect(redactedKeys).toEqual(['options.auth.password']); + }); + + it('scrubs a stored legacy `driver: "mongo"` row identically — aliases resolve (#6345)', () => { + const { redactedKeys } = redactDatasourceConfig('mongo', STORED); + expect(redactedKeys).toEqual(['options.auth.password']); + }); + + it('is pure — the stored input is never mutated', () => { + const input = JSON.parse(JSON.stringify(STORED)); + redactDatasourceConfig('mongodb', input); + expect(input).toEqual(STORED); + }); + + it('drops the binder-slotless client secrets too — proxy, TLS key material, AWS session token', () => { + // Still WRITABLE (the binder has exactly one secret slot — the turso- + // `encryptionKey` posture), but never SERVED: each is honoured (or, for + // AWS_SESSION_TOKEN, refused loudly) by mongodb@7.5.0, so serving it back + // is a leak under any boundary. + const { config, redactedKeys } = redactDatasourceConfig('mongodb', { + options: { + replicaSet: 'rs0', + proxyHost: 'proxy.internal', + proxyUsername: 'svc', + proxyPassword: 'sekret', + tlsCertificateKeyFilePassword: 'passphrase', + key: '-----BEGIN PRIVATE KEY-----', + passphrase: 'p', + authMechanismProperties: { AWS_SESSION_TOKEN: 'tok', SERVICE_NAME: 'mongodb' }, + }, + }); + expect(config).toEqual({ + options: { + replicaSet: 'rs0', + proxyHost: 'proxy.internal', + proxyUsername: 'svc', + authMechanismProperties: { SERVICE_NAME: 'mongodb' }, + }, + }); + expect(redactedKeys).toEqual([ + 'options.authMechanismProperties.AWS_SESSION_TOKEN', + 'options.key', + 'options.passphrase', + 'options.proxyPassword', + 'options.tlsCertificateKeyFilePassword', + ]); + }); + + it('a config without the passthrough — or with a malformed one — is untouched', () => { + expect(redactDatasourceConfig('mongodb', { database: 'events' }).redactedKeys).toEqual([]); + // Off-shape walks fall off silently rather than throwing on a stored row. + expect(redactDatasourceConfig('mongodb', { options: 'not-a-record' }).redactedKeys).toEqual([]); + expect(redactDatasourceConfig('mongodb', { options: { auth: 'not-a-record' } }).redactedKeys) + .toEqual([]); + }); + + it('other drivers have no passthrough today — measured, not assumed', () => { + // postgres/mysql/turso/sqlite/memory ship closed strict-object contracts + // with no client-bound record slot; a nested `password` there is an + // unknown key the write door refuses, not a served secret. + for (const driver of ['postgres', 'mysql', 'turso', 'sqlite', 'sqlite-wasm', 'memory']) { + expect(passthroughSecretPaths(driver), driver).toEqual([]); + } + expect(passthroughSecretPaths('mongodb').length).toBeGreaterThan(0); + expect(passthroughSecretPaths('not-a-real-driver')).toEqual([]); + }); + + it('the write-door subset projects the refusal list — the two doors cannot drift', () => { + expect(refusedPassthroughSecretPaths('mongodb')).toEqual([['options', 'auth', 'password']]); + expect(refusedPassthroughSecretPaths('mongo')).toEqual([['options', 'auth', 'password']]); + expect(refusedPassthroughSecretPaths('postgres')).toEqual([]); + // Every write-door-refused path must be read-door-redacted: a refusal the + // scrub does not mirror would serve back the very material the write door + // calls a secret. + const redacted = new Set(passthroughSecretPaths('mongodb').map((p) => p.join('.'))); + for (const path of refusedPassthroughSecretPaths('mongodb')) { + expect(redacted.has(path.join('.'))).toBe(true); + } + }); +}); diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index 1d5a610946..2101e61940 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -87,8 +87,9 @@ import { CREDENTIAL_URL_QUERY_PARAM_NAMES, credentialQueryParamOf, + MONGO_OPTIONS_CREDENTIAL_PATHS, } from './driver/common.zod'; -import { getDriverConfigSchema } from './driver/config-registry.zod'; +import { getDriverConfigSchema, resolveDriverId } from './driver/config-registry.zod'; /** * Canonical inline-credential spellings, used for a driver whose contract this @@ -126,6 +127,81 @@ const STILL_WRITABLE_CREDENTIAL_KEYS: Record = { turso: ['encryptionKey'], }; +/** + * Secret-bearing paths inside a driver's passthrough `config` slot — the + * FOURTH spelling of the stored credential (#9040), nested where the top-level + * key-name scrub cannot see it. + * + * Only mongo declares a passthrough today (`options`, spread verbatim into + * `MongoClientOptions`); postgres/mysql/turso/sqlite/memory have closed + * strict-object contracts with no client-bound record slot (measured for + * #9040 — memory's `initialData` is seed DATA, deliberately not judged here: + * redacting a seeded row's own `password` FIELD would corrupt data the driver + * serves, which is not this module's question). Every path is measured against + * `mongodb@7.5.0`, the client the driver spreads `options` into: + * + * - `options.auth.password` — resolved into `MongoCredentials`; the login + * secret itself, and the one path the WRITE door also refuses + * (`MONGO_OPTIONS_CREDENTIAL_PATHS` in `driver/common.zod.ts`; #8696 + * measured a bound secret outranking it at connect). `auth.username` is + * deliberately not here — a username is not credential material (#8876). + * - `options.proxyPassword` — SOCKS5 proxy password, honoured + * (`c.options.proxyPassword`, measured). + * - `options.tlsCertificateKeyFilePassword`, `options.key`, + * `options.passphrase` — TLS key material and passphrases, declared in the + * client's own OPTIONS table. Still WRITABLE (the binder has exactly one + * secret slot — the login password — so there is no working refusal remedy; + * turso-`encryptionKey` posture, #8081 item 4), but never SERVED back: + * redacting on read neither grants nor removes the write capability. + * - `options.authMechanismProperties.AWS_SESSION_TOKEN` — under MONGODB-AWS + * the v7 client throws on it; under any other mechanism nothing reads it. + * Either way a stored copy is a secret served in cleartext, and serving it + * back is a leak under any boundary (the same asymmetry with the write + * door this module already documents for the inline keys). + * + * Keyed by CANONICAL driver id and looked up through {@link resolveDriverId}, + * so a stored legacy `driver: 'mongo'` row is scrubbed identically to + * `'mongodb'`. + */ +const PASSTHROUGH_SECRET_PATHS: Readonly> = { + mongodb: [ + ['options', 'auth', 'password'], + ['options', 'proxyPassword'], + ['options', 'tlsCertificateKeyFilePassword'], + ['options', 'key'], + ['options', 'passphrase'], + ['options', 'authMechanismProperties', 'AWS_SESSION_TOKEN'], + ], +}; + +/** + * The nested config paths this module hides for `driver`, dotted-path-ready — + * the passthrough sibling of {@link redactableConfigKeys}, exported so the + * write-path inverse (`service-datasource`'s `restoreRedactedConfig`) mirrors + * exactly the set the read path hides (#9040): a nested redaction the restore + * side did not mirror would turn an untouched "Save" on an affected legacy row + * into silent credential deletion. + */ +export function passthroughSecretPaths(driver: unknown): readonly (readonly string[])[] { + const id = resolveDriverId(driver); + return id ? (PASSTHROUGH_SECRET_PATHS[id] ?? []) : []; +} + +/** + * The config-relative subset of {@link passthroughSecretPaths} the WRITE door + * also refuses (#9040) — today `options.auth.password` on mongo, projected + * from the write door's own closed list (`MONGO_OPTIONS_CREDENTIAL_PATHS`) so + * the two doors cannot drift. What the credential-migration planner consults: + * a stored row carrying one of these holds a LIVE login credential the binder + * substitutes but cannot mechanically extract (dropping the nested leaf would + * leave an `auth` block the client throws on). + */ +export function refusedPassthroughSecretPaths(driver: unknown): readonly (readonly string[])[] { + return resolveDriverId(driver) === 'mongodb' + ? MONGO_OPTIONS_CREDENTIAL_PATHS.map((path) => ['options', ...path]) + : []; +} + /** Unwrap `.optional()` / `.default()` / `.nullable()` down to the base type. */ function baseTypeOf(schema: unknown): string | undefined { let node: any = schema; @@ -297,5 +373,43 @@ export function redactDatasourceConfig( out[key] = value; } - return { config: out, redactedKeys: redactedKeys.sort() }; + // The passthrough spellings (#9040): nested secret material the top-level + // key-name scrub cannot see. Dropped, not masked, for the same round-trip + // reason as the inline keys; each removal is reported as its DOTTED path + // (`options.auth.password`), which is the shape the metadata write door's + // generic carry-forward (`carryForwardRedactedValues`) already walks. + let scrubbed: Record = out; + for (const path of passthroughSecretPaths(driver)) { + const [next, dropped] = withoutPath(scrubbed, path); + if (dropped) { + scrubbed = next; + redactedKeys.push(path.join('.')); + } + } + + return { config: scrubbed, redactedKeys: redactedKeys.sort() }; +} + +/** + * `container` without the leaf at `path`, copying only the spine — pure, like + * everything else on this read path. `dropped` is `false` when the walk falls + * off the shape (a missing or non-record segment) or the leaf is absent, in + * which case the input is returned untouched so "did anything change?" stays a + * usable question. + */ +function withoutPath( + container: Record, + path: readonly string[], +): [Record, boolean] { + const [head, ...rest] = path as [string, ...string[]]; + const value = container[head]; + if (rest.length === 0) { + if (value === undefined) return [container, false]; + const { [head]: _dropped, ...kept } = container; + return [kept, true]; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return [container, false]; + const [child, dropped] = withoutPath(value as Record, rest); + if (!dropped) return [container, false]; + return [{ ...container, [head]: child }, true]; } diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index ceaf6ca53d..dd84dff7dd 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -338,6 +338,113 @@ export const URL_CREDENTIAL_QUERY_PARAM_REFUSED = (key: string, param: string): + 'themselves refused at publish (#8336). Runtime-environment DSNs (`OS_DATABASE_URL` and ' + 'friends) do not pass through this publish door and are unaffected.'; +/** + * Refusal prescription for a credential written into the MongoClient + * passthrough (`config.options.auth.password`) — the FOURTH spelling of the + * same inline secret (#9040): #7990 refused the top-level key, #8082 the URL + * userinfo, #8337 the URL query parameter, and the `options` passthrough was + * the next syntax over from all three, exactly as #8337 was one syntax over + * from #8082. + * + * Same wording constraints as the sibling messages, plus one this message may + * state that #8337's must not: the bound secret genuinely WINS over a + * passthrough `auth` block at connect — measured by #8696's pin + * (`bound-secret-dsn-branches.test.ts`), which asserts the injected + * `external.credentialsRef` secret outranks `options.auth`. So the "wins over" + * reassurance is true here, unlike turso's query form where the URL token + * defeats the binder. + */ +export const PASSTHROUGH_INLINE_CREDENTIAL_REFUSED = (path: string): string => + `\`${path}\` is a credential and is not accepted in the driver-options passthrough (#9040): ` + + 'the datasource is persisted whole into `sys_metadata`, which is served back by the ' + + 'ordinary data API, so a passthrough credential lands in cleartext at rest exactly like an ' + + 'inline `password` (#7990), a URL userinfo password (#8082) or a credential query ' + + 'parameter (#8337). Remove the `auth` block\'s password and bind the secret instead: the ' + + "Setup → Datasources connection form's secret field hands it to the datasource secret " + + 'binder, which encrypts it into `sys_secret` and stores only an opaque handle at ' + + '`external.credentialsRef` — or reference the secrets store directly with ' + + '`external.credentialsRef`. The resolved secret is injected at connect time and wins over ' + + 'an `auth` block embedded in the passthrough (#8696, measured). Placeholders are no ' + + 'escape either: a `${…}` span anywhere in `options` is itself refused at publish (#8336).'; + +/** + * The paths inside mongo's `options` passthrough that resolve into a login + * credential the client honours AND the secret binder can replace — the CLOSED + * refusal list behind {@link credentialFreeMongoOptions} (#9040). + * + * Every entry is MEASURED against `mongodb@7.5.0`, the client + * `@objectstack/driver-mongodb` pins and spreads `config.options` into + * (`new MongoClient(url, { …, ...config.options })`) — never inferred from + * documentation: + * + * - `auth.password` — `OPTIONS.auth` transforms `{ username, password }` into + * `MongoCredentials`, so the passthrough password IS the login credential + * (measured: `c.options.credentials.password` carries it verbatim). The + * binder replaces it exactly: #8696's pin measures a bound + * `external.credentialsRef` secret outranking this block at connect. Only a + * NON-EMPTY STRING is refused — `auth.username` alone is not credential + * material (#8876's asymmetry, restated for this syntax), an empty password + * is the passthrough twin of `user:@host` (accepted, #8082), and a + * non-string value is not a secret the client accepts (its + * `MongoCredentials` validation fails loudly at construction). + * + * Deliberately absent, each measured (refusing them would be the speculative + * widening #8337 forbids, or a refusal pointing at a remedy that cannot work): + * + * - `authMechanismProperties.AWS_SESSION_TOKEN` — under `authMechanism: + * 'MONGODB-AWS'` the client itself THROWS on it (`MongoAPIError: + * AWS_SESSION_TOKEN cannot be provided…`, measured — driver v7 requires AWS + * SDK-sourced credentials); under any other mechanism nothing reads it. An + * author who writes it gets a loud client failure, not a silent workaround. + * The read path still redacts it ("leak under any boundary"). + * - `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, `passphrase` — + * honoured by the client (SOCKS5 proxy auth; TLS key material), but the + * secret binder injects exactly ONE secret and that slot is the login + * password, so a refusal here would name a remedy that does not exist and + * remove the only way to configure an authenticated proxy / passphrase- + * protected key. Same posture as turso's still-writable `encryptionKey` + * (#8078; the binder-slot question is #8081 item 4's, not this refusal's). + * They are redacted on read instead + * (`data/datasource-credential-redaction.ts`). + */ +export const MONGO_OPTIONS_CREDENTIAL_PATHS: readonly (readonly string[])[] = [ + ['auth', 'password'], +]; + +/** The value at `path` inside a record-ish value, or `undefined` off the walk. */ +function valueAtPath(value: unknown, path: readonly string[]): unknown { + let node: unknown = value; + for (const segment of path) { + if (!node || typeof node !== 'object' || Array.isArray(node)) return undefined; + node = (node as Record)[segment]; + } + return node; +} + +/** + * Attach the #9040 passthrough-credential refusal to mongo's `options` slot. + * + * Composes with `placeholderFreeDeep` the same way `credentialFreeUrl` + * composes with `placeholderFree` on the URL keys: both checks are + * `superRefine`s judging the same value independently, an input violating both + * reports both, and neither changes the other's semantics. Each finding is + * reported at its own path so the author is pointed at the exact entry. + */ +export function credentialFreeMongoOptions(schema: S, key: string) { + return schema.superRefine((value, ctx) => { + for (const path of MONGO_OPTIONS_CREDENTIAL_PATHS) { + const leaf = valueAtPath(value, path); + if (typeof leaf === 'string' && leaf.length > 0) { + ctx.addIssue({ + code: 'custom', + path: [...path], + message: PASSTHROUGH_INLINE_CREDENTIAL_REFUSED([key, ...path].join('.')), + }); + } + } + }); +} + /** Percent-decode a query key the way the measured clients do; malformed encoding stays raw. */ function decodeQueryKey(raw: string): string { // `.replace` with a global regex, not `.replaceAll`: the DTS build's lib 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 2e72751886..e8aabf38b2 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -594,3 +594,108 @@ describe('urlUserinfoUsername — the username half of the same grammar (#8876)' expect(urlUserinfoUsername('//h/db')).toBeUndefined(); }); }); + +/** + * The passthrough spelling of the same secret (#9040) — the FOURTH: #7990 + * refused the top-level key, #8082 the URL userinfo, #8337 the URL query + * parameter, and `options.auth.password` was the next syntax over. Measured on + * mongodb@7.5.0 (the client `@objectstack/driver-mongodb` spreads + * `config.options` into): the block is transformed into `MongoCredentials`, so + * the passthrough password authenticated for real while sitting cleartext in + * `sys_metadata`. + * + * Envelope note (same as the #8082 pin 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('mongo options passthrough — credential refusal (#9040)', () => { + const VALID = { database: 'events', host: 'mongo.internal', username: 'svc' } as const; + const refusalAt = (options: Record) => { + const result = MongoConfigSchema.safeParse({ ...VALID, options }); + if (result.success) return undefined; + return result.error.issues.find((i) => i.path.join('.') === 'options.auth.password'); + }; + + it('refuses a non-empty `auth.password`, naming the mechanisms — and the true precedence', () => { + const issue = refusalAt({ auth: { username: 'app', password: 'hunter2' } }); + expect(issue, 'refusal must be pathed at `options.auth.password`').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('`options.auth.password`'); + expect(issue!.message).toContain('external.credentialsRef'); + expect(issue!.message).toContain('sys_secret'); + expect(issue!.message).toContain('secret binder'); + // Unlike #8337's query form, the "wins over" reassurance is TRUE here and + // load-bearing: #8696's pin measures the bound secret outranking a + // passthrough `auth` block at connect. + expect(issue!.message).toContain('wins over'); + }); + + it('refuses a `${…}` placeholder password exactly like a real one — and both doors report', () => { + // A placeholder is a non-empty string, so the credential refusal fires; + // `placeholderFreeDeep` (#8336) judges the same value independently. The + // two `superRefine`s compose without changing each other's semantics — + // the PM-mechanism assumption this pin verifies. + const result = MongoConfigSchema.safeParse({ + ...VALID, + options: { auth: { username: 'app', password: '${DB_PASSWORD}' } }, + }); + expect(result.success).toBe(false); + const at = result.error!.issues.filter((i) => i.path.join('.') === 'options.auth.password'); + expect(at.length).toBe(2); + const texts = at.map((i) => i.message).join('\n'); + expect(texts).toContain('#9040'); + expect(texts).toContain('#8336'); + }); + + it('re-paths the refusal under `config.options.auth.password` on the authored artefact', () => { + const result = DatasourceSchema.safeParse({ + name: 'events', + driver: 'mongodb', + config: { database: 'events', options: { auth: { username: 'app', password: 'hunter2' } } }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'config.options.auth.password', + ); + expect(issue, 'issue must be re-pathed under config.options.auth.password').toBeDefined(); + expect(issue!.message).toContain('external.credentialsRef'); + }); + + it('`auth.username` alone is NOT credential material (#8876 asymmetry) — stays accepted', () => { + // The schema's question is "is a secret being persisted?", and a username + // is not one. (The client separately refuses a username-only `auth` block + // at construction — `credentials must be an object with 'username' and + // 'password' properties`, measured — a loud connect-time failure that is + // the client's own contract to enforce, not this door's.) + expect(refusalAt({ auth: { username: 'app' } })).toBeUndefined(); + }); + + it('an EMPTY `auth.password` carries no secret — the passthrough twin of `user:@host` (#8082)', () => { + expect(refusalAt({ auth: { username: 'app', password: '' } })).toBeUndefined(); + }); + + it('a non-string `auth.password` is not a secret the client accepts — left to its loud validation', () => { + // MongoCredentials validation refuses a non-string password at + // construction (measured); refusing it here as credential material would + // claim a secret where the client sees a type error. + expect(refusalAt({ auth: { username: 'app', password: 42 } })).toBeUndefined(); + }); + + it('accepts the legitimate passthrough byte-identically (pin) — replicaSet, tls, timeouts', () => { + // The dispatch fence: the refusal must not break what the passthrough is + // FOR. Includes the redacted round-trip shape (`auth` with only a + // username) — what the #9040 read path serves for an affected legacy row, + // and what the Studio edit form PUTs back on an untouched "Save". + for (const options of [ + { replicaSet: 'rs0', tls: true, connectTimeoutMS: 5000, serverSelectionTimeoutMS: 3000 }, + { auth: { username: 'app' }, replicaSet: 'rs0' }, + ]) { + const config = { ...VALID, options }; + const before = MongoConfigSchema.safeParse(config); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + expect(MongoConfigSchema.parse(config)).toEqual(before.data); + expect(before.data!.options).toEqual(options); + } + }); +}); diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 1fde1c0390..ae0c148f47 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -6,6 +6,7 @@ import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { + credentialFreeMongoOptions, credentialFreeUrl, driverConfigJsonSchema, INLINE_CREDENTIAL_REFUSED, @@ -123,10 +124,19 @@ export const MongoConfigSchema = lazySchema(() => strictObject( * (`replicaSet`, `tls`, timeouts, …). Placeholder-free since #8336, judged * DEEP: every nested string value reaches the client, and this passthrough * is exactly where a refusal on `url`/`host` would otherwise displace the - * placeholder to. + * placeholder to. Credential-free since #9040 — `auth.password` was the + * FOURTH spelling of the inline secret (after the top-level key #7990, URL + * userinfo #8082 and URL query params #8337): the client resolves the block + * into `MongoCredentials`, so a passthrough password authenticated for real + * while sitting cleartext in `sys_metadata`. A non-empty `auth.password` is + * refused with the binder prescription; `auth.username` stays writable + * (#8876's asymmetry — a username is not credential material). */ - options: placeholderFreeDeep(z.record(z.string(), z.unknown()), 'options').optional() - .describe('Extra MongoClient options (replicaSet, tls, timeouts, …)'), + options: credentialFreeMongoOptions( + placeholderFreeDeep(z.record(z.string(), z.unknown()), 'options'), + 'options', + ).optional() + .describe('Extra MongoClient options (replicaSet, tls, timeouts, …; credential material is refused — bind secrets via the connection form / external.credentialsRef)'), }) .describe('MongoDB Connection Configuration') .superRefine((cfg, ctx) => { diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts new file mode 100644 index 0000000000..50bd1c9b76 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-mongo-options-credential-refused', + surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + + 'into the MongoClient options passthrough', + replacement: 'remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, ' + + 'timeouts — stay legal) and bind the secret: the Setup → Datasources connection form\'s ' + + 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + + 'or a direct `external.credentialsRef` secrets-store reference, with the username kept in ' + + 'the URL (`mongodb://user@host/db`)', + reason: + 'The FOURTH spelling of the same inline secret: #7990 refused the top-level `password` ' + + 'key, #8082 the URL userinfo, #8337 credential query parameters — and the `options` ' + + 'passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + + 'parsed green, persisted the password cleartext into `sys_metadata` (served back by the ' + + 'ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block ' + + 'into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty ' + + 'string `auth.password` is now refused at publish with the binder prescription; ' + + '`auth.username` alone stays writable (#8876\'s asymmetry — a username is not credential ' + + 'material), as do all non-credential passthrough options. The bound secret wins over a ' + + 'passthrough `auth` block at connect (#8696, measured), so the replacement changes which ' + + 'store holds the secret, never which credential connects. There is no mechanical ' + + 'rewrite, for the same reason as the sibling entries ' + + '`datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` ' + + 'and `datasource-config-url-query-credential-refused`: moving the value requires ' + + 'ENCRYPTING it into a `sys_secret` row through a running secret binder, which a ' + + 'source-file transform cannot do — and auto-dropping only the nested password would ' + + 'leave an `auth` block the client refuses at construction (measured: `credentials must ' + + 'be an object with \'username\' and \'password\' properties`). Runtime-environment DSNs ' + + '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + + 'by construction. The read path now also redacts the stored passthrough secrets ' + + '(`options.auth.password`, `options.proxyPassword`, TLS key material, ' + + '`AWS_SESSION_TOKEN`) instead of serving them back in cleartext.', + acceptanceCriteria: + 'Every mongodb datasource parses with no `auth.password` inside `config.options`; each ' + + 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + + 'the connection form) with the username in its URL, and still connects; no passthrough ' + + 'credential remains in any stored `sys_metadata` row or authored source.', +}; From e3b754e23348d8c8c58a1f13d07868dcd99ad5e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:37:09 +0000 Subject: [PATCH 2/2] chore(spec): regenerate migration registry, api-surface, export-origins, references; add changeset (#9040) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq --- ...config-mongo-options-credential-refused.md | 78 +++++++++++++++++++ content/docs/references/data/driver-mongo.mdx | 2 +- packages/spec/api-surface/data.json | 5 ++ packages/spec/export-origins/data.json | 5 ++ packages/spec/src/migrations/registry.ts | 38 +++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .changeset/datasource-config-mongo-options-credential-refused.md diff --git a/.changeset/datasource-config-mongo-options-credential-refused.md b/.changeset/datasource-config-mongo-options-credential-refused.md new file mode 100644 index 0000000000..0eaa8ca3d2 --- /dev/null +++ b/.changeset/datasource-config-mongo-options-credential-refused.md @@ -0,0 +1,78 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-datasource": patch +--- + +feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). + +The FOURTH spelling of the same inline secret: #7990 refused the top-level +`password` key, #8082 the URL userinfo (`user:password@host`), #8337 the +credential-bearing URL query parameters — and the MongoClient `options` +passthrough stayed open one syntax over. +`options: { auth: { username, password } }` parsed green, persisted the +password cleartext into `sys_metadata` (served back by the ordinary data API, +unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the +client the driver spreads `config.options` into, the block is transformed into +`MongoCredentials` — so the workaround was live, not inert. + +**What is refused** (write door, closed measured list +`MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed +with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING +`options.auth.password`, with the binder prescription — and the "wins over" +reassurance is true for this syntax: a bound `external.credentialsRef` secret +outranks the passthrough `auth` block at connect (#8696, measured). +Deliberately not refused, each measured: `auth.username` alone (#8876's +asymmetry — a username is not credential material), an empty password (the +passthrough twin of `user:@host`), every legitimate passthrough option +(`replicaSet`, `tls`, timeouts — byte-identical pins), +`authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it +under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless +client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, +`passphrase`) — refusing those would name a remedy that does not exist (the +binder fills exactly one slot; the turso-`encryptionKey` posture, #8081 +item 4). + +**Read half** (additive, never the substitute — #8082's ruling): stored +passthrough secrets are now redacted on every read exit — +`options.auth.password` plus the binder-slotless names above and +`AWS_SESSION_TOKEN` — reported as dotted `redactedKeys` +(`options.auth.password`), which the metadata write door's generic +carry-forward already walks, so an untouched "Save" keeps the stored +credential on both admin doors (`restoreRedactedConfig` mirrors per leaf). +The #8155 credential-migration planner refuses a stored passthrough-credential +row with the per-row remedy instead of planning `nothing-to-migrate` over live +cleartext (dropping only the nested leaf would leave an `auth` block the +client refuses at construction, measured). + +## FROM → TO + +```yaml +# before — parsed green; password stored cleartext in sys_metadata and +# resolved into MongoCredentials at connect +driver: mongodb +config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 + auth: { username: app, password: PLAINTEXT-IN-METADATA } + +# after — rejected with the binder prescription; bind the secret instead +driver: mongodb +config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 +external: + credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field +``` + +There is deliberately no automatic rewrite: moving the value requires +encrypting it into `sys_secret` through a running secret binder, which a +source-file transform cannot do — and auto-dropping only the nested password +would leave an `auth` block the MongoDB client refuses outright. + + diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx index 4ee5d18389..a8a93b7e40 100644 --- a/content/docs/references/data/driver-mongo.mdx +++ b/content/docs/references/data/driver-mongo.mdx @@ -48,7 +48,7 @@ MongoDB Connection Configuration | **username** | `string` | optional | Authentication user | | **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | | **authSource** | `string` | optional | Authentication database | -| **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) | +| **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …; credential material is refused — bind secrets via the connection form / external.credentialsRef) | --- diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index fcda339ab9..2cee5f69ee 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -357,6 +357,7 @@ "MANAGED_WRITE_VERB_AFFORDANCE (const)", "MAX_BULK_PER_ROW_HOOK_ROWS (const)", "MEASURE_FIELD_TYPES (const)", + "MONGO_OPTIONS_CREDENTIAL_PATHS (const)", "MULTI_CAPABLE_TYPES (const)", "MULTI_OPTION_TYPES (const)", "ManagedApiMethodConflict (interface)", @@ -439,6 +440,7 @@ "PAGINATION_ROWS (const)", "PAGINATION_UNORDERED_CASES (const)", "PAGINATION_ZERO_LIMIT_CASES (const)", + "PASSTHROUGH_INLINE_CREDENTIAL_REFUSED (const)", "PaginationConformanceCase (interface)", "PaginationConformanceRow (interface)", "PerOperationRequiredPermissions (type)", @@ -634,6 +636,7 @@ "classifyFilterToken (function)", "containsUnresolvedPlaceholder (function)", "countAuthorableFields (function)", + "credentialFreeMongoOptions (function)", "credentialFreeUrl (function)", "credentialQueryParamOf (function)", "defaultAggregateFor (function)", @@ -710,6 +713,7 @@ "parseAutonumberFormat (function)", "parseDateMacroParam (function)", "parseFilterAST (function)", + "passthroughSecretPaths (function)", "percentScaleOf (function)", "placeholderFree (function)", "placeholderFreeDeep (function)", @@ -727,6 +731,7 @@ "referencedFields (function)", "refusedCredentialKeys (function)", "refusedInlineCredentialKey (function)", + "refusedPassthroughSecretPaths (function)", "renderAutonumber (function)", "resolveAutonumberFormat (function)", "resolveBulkPerRowHookBudget (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index ffbaea8c9b..598be8ff61 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -357,6 +357,7 @@ "MANAGED_WRITE_VERB_AFFORDANCE": "src/data/managed-api-affordance.ts#MANAGED_WRITE_VERB_AFFORDANCE (const)", "MAX_BULK_PER_ROW_HOOK_ROWS": "src/data/bulk-write-hook-conformance.ts#MAX_BULK_PER_ROW_HOOK_ROWS (const)", "MEASURE_FIELD_TYPES": "src/data/aggregation-policy.ts#MEASURE_FIELD_TYPES (const)", + "MONGO_OPTIONS_CREDENTIAL_PATHS": "src/data/driver/common.zod.ts#MONGO_OPTIONS_CREDENTIAL_PATHS (const)", "MULTI_CAPABLE_TYPES": "src/data/field-value.zod.ts#MULTI_CAPABLE_TYPES (const)", "MULTI_OPTION_TYPES": "src/data/field-value.zod.ts#MULTI_OPTION_TYPES (const)", "ManagedApiMethodConflict": "src/data/managed-api-affordance.ts#ManagedApiMethodConflict (interface)", @@ -439,6 +440,7 @@ "PAGINATION_ROWS": "src/data/pagination-conformance.ts#PAGINATION_ROWS (const)", "PAGINATION_UNORDERED_CASES": "src/data/pagination-conformance.ts#PAGINATION_UNORDERED_CASES (const)", "PAGINATION_ZERO_LIMIT_CASES": "src/data/pagination-conformance.ts#PAGINATION_ZERO_LIMIT_CASES (const)", + "PASSTHROUGH_INLINE_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#PASSTHROUGH_INLINE_CREDENTIAL_REFUSED (const)", "PaginationConformanceCase": "src/data/pagination-conformance.ts#PaginationConformanceCase (interface)", "PaginationConformanceRow": "src/data/pagination-conformance.ts#PaginationConformanceRow (interface)", "PerOperationRequiredPermissions": "src/data/object.zod.ts#PerOperationRequiredPermissions (type)", @@ -634,6 +636,7 @@ "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "containsUnresolvedPlaceholder": "src/data/driver/common.zod.ts#containsUnresolvedPlaceholder (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", + "credentialFreeMongoOptions": "src/data/driver/common.zod.ts#credentialFreeMongoOptions (function)", "credentialFreeUrl": "src/data/driver/common.zod.ts#credentialFreeUrl (function)", "credentialQueryParamOf": "src/data/driver/common.zod.ts#credentialQueryParamOf (function)", "defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)", @@ -710,6 +713,7 @@ "parseAutonumberFormat": "src/data/autonumber-format.ts#parseAutonumberFormat (function)", "parseDateMacroParam": "src/data/date-macros.zod.ts#parseDateMacroParam (function)", "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", + "passthroughSecretPaths": "src/data/datasource-credential-redaction.ts#passthroughSecretPaths (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", "placeholderFree": "src/data/driver/common.zod.ts#placeholderFree (function)", "placeholderFreeDeep": "src/data/driver/common.zod.ts#placeholderFreeDeep (function)", @@ -727,6 +731,7 @@ "referencedFields": "src/data/autonumber-format.ts#referencedFields (function)", "refusedCredentialKeys": "src/data/datasource-credential-redaction.ts#refusedCredentialKeys (function)", "refusedInlineCredentialKey": "src/data/driver/common.zod.ts#refusedInlineCredentialKey (function)", + "refusedPassthroughSecretPaths": "src/data/datasource-credential-redaction.ts#refusedPassthroughSecretPaths (function)", "renderAutonumber": "src/data/autonumber-format.ts#renderAutonumber (function)", "resolveAutonumberFormat": "src/data/autonumber-format.ts#resolveAutonumberFormat (function)", "resolveBulkPerRowHookBudget": "src/data/bulk-write-hook-conformance.ts#resolveBulkPerRowHookBudget (function)", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4698f2f041..b8ac9c6f91 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4995,6 +4995,44 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'datasource-config-mongo-options-credential-refused', + surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + + 'into the MongoClient options passthrough', + replacement: 'remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, ' + + 'timeouts — stay legal) and bind the secret: the Setup → Datasources connection form\'s ' + + 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + + 'or a direct `external.credentialsRef` secrets-store reference, with the username kept in ' + + 'the URL (`mongodb://user@host/db`)', + reason: + 'The FOURTH spelling of the same inline secret: #7990 refused the top-level `password` ' + + 'key, #8082 the URL userinfo, #8337 credential query parameters — and the `options` ' + + 'passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + + 'parsed green, persisted the password cleartext into `sys_metadata` (served back by the ' + + 'ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block ' + + 'into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty ' + + 'string `auth.password` is now refused at publish with the binder prescription; ' + + '`auth.username` alone stays writable (#8876\'s asymmetry — a username is not credential ' + + 'material), as do all non-credential passthrough options. The bound secret wins over a ' + + 'passthrough `auth` block at connect (#8696, measured), so the replacement changes which ' + + 'store holds the secret, never which credential connects. There is no mechanical ' + + 'rewrite, for the same reason as the sibling entries ' + + '`datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` ' + + 'and `datasource-config-url-query-credential-refused`: moving the value requires ' + + 'ENCRYPTING it into a `sys_secret` row through a running secret binder, which a ' + + 'source-file transform cannot do — and auto-dropping only the nested password would ' + + 'leave an `auth` block the client refuses at construction (measured: `credentials must ' + + 'be an object with \'username\' and \'password\' properties`). Runtime-environment DSNs ' + + '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + + 'by construction. The read path now also redacts the stored passthrough secrets ' + + '(`options.auth.password`, `options.proxyPassword`, TLS key material, ' + + '`AWS_SESSION_TOKEN`) instead of serving them back in cleartext.', + acceptanceCriteria: + 'Every mongodb datasource parses with no `auth.password` inside `config.options`; each ' + + 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + + 'the connection form) with the username in its URL, and still connects; no passthrough ' + + 'credential remains in any stored `sys_metadata` row or authored source.', + }, { id: 'datasource-config-url-query-credential-refused', surface: 'datasource.config.url / datasource.config.syncUrl (turso) and ' +