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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .changeset/datasource-config-mongo-options-credential-refused.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: registered datasource-config-mongo-options-credential-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-mongo.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) |
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …; credential material is refused — bind secrets via the connection form / external.credentialsRef) |


---
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>), 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<string, unknown>;
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/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>): 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: [] });
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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<string, unknown>)) 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<string, unknown>)[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<string, unknown>, path: readonly string[], value: unknown): void {
let node = out;
for (const segment of path.slice(0, -1)) {
const child = { ...(node[segment] as Record<string, unknown>) };
node[segment] = child;
node = child;
}
node[path[path.length - 1] as string] = value;
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,6 +74,7 @@ import {
redactableConfigKeys,
redactUrlCredentials,
refusedCredentialKeys,
refusedPassthroughSecretPaths,
validateDriverConfig,
} from '@objectstack/spec/data';
import type { StoredDatasource } from './datasource-admin-service.js';
Expand DownExpand Up@@ -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<string, unknown>)[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 {
Expand Down
Loading
Loading