Skip to content

Commit 51ecb2f

Browse files
os-warrenclaude
andauthored
feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror (#13604)
A credential under the very spelling the top level refuses and redacts - one object level down (options.auth.token, options.pool.password, tunnel.password on a contract-less driver) - was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. The nested judgment was a hand-enumerated per-driver path table on the read side and absent on the write side, while the top level was derived from the driver contract. Both sides now consume one derivation: the canonical spellings and former aliases move to driver/common.zod.ts (CREDENTIAL_KEY_SPELLINGS, the bottom of the import graph); the read scrub applies the name judgment and the URL composite at every object depth for every driver; the write door's passthrough walk refuses the same spellings at any depth; refusedCredentialPaths walks nested object shapes for z.never leaves; arrays stay off the walk on both doors (row-shaped data is not config). passthroughSecretPaths remains only as the client-measured residue. restoreRedactedConfig is now derived from the redactor's own redactedPaths, so every current and future redaction source is mirrored on the untouched-Save round trip by construction. Semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) carries the authored-artifact upgrade. Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87075b1 commit 51ecb2f

14 files changed

Lines changed: 812 additions & 98 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
feat(spec): treat a nested datasource-config credential position identically to the top-level key it mirrors — derived at every depth, on both doors (#13405)
7+
8+
**BREAKING** accept-set narrowing, the nested closure of the #9040 family. A
9+
credential under the very spelling the top level refuses and redacts — one
10+
object level down (`options.auth.token`, `options.pool.password`, a
11+
`tunnel.password` on a contract-less driver) — was accepted at publish and
12+
served back by every datasource read door in cleartext with
13+
`redactedConfigKeys: []`, because the read side's nested judgment was only the
14+
hand-enumerated `passthroughSecretPaths` table and the write side had no nested
15+
name judgment at all.
16+
17+
Both sides are now derived from ONE source instead of hand-maintained:
18+
19+
- The canonical credential spellings and former aliases moved to
20+
`driver/common.zod.ts` (`CREDENTIAL_KEY_SPELLINGS`) — the bottom of the
21+
driver-schema import graph — so the write door's passthrough walk and the
22+
read redactor consume the same list (#8300's no-second-copy posture applied
23+
to the list itself).
24+
- **Read door** (`redactDatasourceConfig`, behind both consumers — the
25+
datasource-admin routes and the kernel per-type redaction hook): the
26+
credential-name judgment and the URL composite (userinfo + query params) now
27+
run at EVERY object depth, for every driver, contract-less included. Nested
28+
removals are reported as dotted paths in `redactedKeys`, plus a new
29+
`redactedPaths` field carrying exact segments. `passthroughSecretPaths`
30+
remains only as the residue it always should have been: CLIENT-MEASURED
31+
secret spellings (`proxyPassword`, `key`, `passphrase`, …) that mirror no
32+
top-level key.
33+
- **Write door** (`credentialFreeMongoOptions`): a non-empty string under a
34+
credential-spelled key is refused at any object depth of the mongodb
35+
`options` passthrough, with a prescription that does not inherit the
36+
`auth.password`-only "wins over" reassurance. The measured `auth.password`
37+
refusal keeps its own message; nothing is double-reported.
38+
- **Schema derivation walked at depth**: `refusedCredentialPaths` /
39+
`refusedCredentialPathsOfSchema` extend the `z.never()` derivation below the
40+
top level, so a driver contract that refuses a key inside a nested object
41+
shape is covered the day it lands (none exists today — pinned per driver).
42+
- **Arrays are off the walk** on both doors — the same structural line
43+
`valueAtPath`/`withoutPath` already drew — so row-shaped data (memory's
44+
`initialData` seeds) keeps its own fields without a per-driver exclusion
45+
list.
46+
- `restoreRedactedConfig` (service-datasource) is now DERIVED from the
47+
redactor instead of mirroring it rule by rule: it grafts stored material
48+
back wherever the patch is indistinguishable from what the read path served,
49+
so an untouched "Save" on an affected legacy row keeps its stored material
50+
for every current and future redaction source, and an author's edit always
51+
wins. The metadata write door's generic `carryForwardRedactedValues` already
52+
walks the dotted paths and needs no change.
53+
54+
Semantic migration entry
55+
`datasource-config-options-nested-credential-spelling-refused` (protocol major
56+
18) carries the authored-artifact upgrade: remove the nested key, or bind the
57+
real secret through `external.credentialsRef` / the connection form.
58+
59+
<!-- adr-0087: registered datasource-config-options-nested-credential-spelling-refused -->

content/docs/references/data/driver-mongo.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ MongoDB Connection Configuration
4848
| **username** | `string` | optional | Authentication user |
4949
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
5050
| **authSource** | `string` | optional | Authentication database |
51-
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …). Only `auth.password` is refused inline — bind it via the connection form / external.credentialsRef. `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted and stored at rest in cleartext; they're redacted only when the datasource is read back, not refused at write. |
51+
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …). Credential-spelled keys (`password`, `authToken`, and their former aliases) are refused inline at any depth — bind the secret via the connection form / external.credentialsRef. `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted and stored at rest in cleartext; they're redacted only when the datasource is read back, not refused at write. |
5252

5353

5454
---

packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,3 +475,78 @@ describe('#9040 — the passthrough spelling, both halves at the service door',
475475
).rejects.toThrow(/options\.auth\.password/);
476476
});
477477
});
478+
479+
describe('nested credential positions OFF the passthrough table — the class control, at this door', () => {
480+
/**
481+
* The nested-position finding, at the admin door: a credential spelling one
482+
* object level down from the key the top level hides — deliberately NOT a
483+
* `passthroughSecretPaths` row — used to be served by `getDatasource()` in
484+
* cleartext with `redactedConfigKeys: []`, and accepted by the write gate in
485+
* silence (measured on the pre-fix build). Regression cases against table
486+
* rows were already green and prove nothing about this class.
487+
*/
488+
const OFF_TABLE_MONGO: StoredDatasource = {
489+
name: 'off_table_mongo',
490+
driver: 'mongodb',
491+
origin: 'runtime',
492+
config: {
493+
database: 'events',
494+
options: { replicaSet: 'rs0', auth: { username: 'app', token: 'eyJhbGci.OFFTABLE.y' } },
495+
},
496+
};
497+
498+
it('read path: the off-table nested credential does not reach the caller, and is named', async () => {
499+
const { service } = makeService([OFF_TABLE_MONGO]);
500+
const read = await service.getDatasource('off_table_mongo');
501+
expect(JSON.stringify(read!.config)).not.toContain('OFFTABLE');
502+
expect(read!.config!.options).toEqual({ replicaSet: 'rs0', auth: { username: 'app' } });
503+
expect(read!.redactedConfigKeys).toContain('options.auth.token');
504+
});
505+
506+
it('an untouched round-trip keeps the stored off-table credential — the restore mirrors the recursion', async () => {
507+
const { service, records } = makeService([OFF_TABLE_MONGO]);
508+
const read = await service.getDatasource('off_table_mongo');
509+
await service.updateDatasource('off_table_mongo', { config: read!.config, label: 'Renamed' });
510+
expect(records[0].label).toBe('Renamed');
511+
expect((records[0].config!.options as any).auth).toEqual({
512+
username: 'app',
513+
token: 'eyJhbGci.OFFTABLE.y',
514+
});
515+
});
516+
517+
it('a TYPED-IN off-table nested credential is refused at the write gate on its own merits', async () => {
518+
const { service } = makeService([OFF_TABLE_MONGO]);
519+
await expect(
520+
service.updateDatasource('off_table_mongo', {
521+
config: {
522+
database: 'events',
523+
options: { auth: { username: 'app', token: 'typed-new-secret' } },
524+
},
525+
}),
526+
).rejects.toThrow(/options\.auth\.token/);
527+
});
528+
529+
it('a NESTED URL string is redacted and restored like a top-level one — contract-less driver included', async () => {
530+
const stored: StoredDatasource = {
531+
name: 'vendor_ds',
532+
driver: 'com.vendor.custom',
533+
origin: 'runtime',
534+
config: { endpoint: 'x', replication: { url: 'postgresql://svc:hunter2@replica/db' } },
535+
};
536+
const { service, records } = makeService([stored]);
537+
const read = await service.getDatasource('vendor_ds');
538+
expect((read!.config!.replication as any).url).toBe('postgresql://svc@replica/db');
539+
expect(read!.redactedConfigKeys).toContain('replication.url');
540+
// Untouched round-trip: the served (redacted) URL is indistinguishable
541+
// from the stored one once redacted, so the stored value is carried back.
542+
await service.updateDatasource('vendor_ds', { config: read!.config });
543+
expect((records[0].config!.replication as any).url).toBe('postgresql://svc:hunter2@replica/db');
544+
// An author who rewrites the nested URL by hand still WINS.
545+
const edited = {
546+
...read!.config,
547+
replication: { url: 'postgresql://svc@other-replica/db' },
548+
};
549+
await service.updateDatasource('vendor_ds', { config: edited });
550+
expect((records[0].config!.replication as any).url).toBe('postgresql://svc@other-replica/db');
551+
});
552+
});

packages/services/service-datasource/src/datasource-config-redaction.ts

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,7 @@
3131
* #8154's, deliberately not built here.
3232
*/
3333

34-
import {
35-
passthroughSecretPaths,
36-
redactableConfigKeys,
37-
redactUrlCredentials,
38-
} from '@objectstack/spec/data';
34+
import { redactDatasourceConfig } from '@objectstack/spec/data';
3935

4036
export {
4137
refusedCredentialKeys,
@@ -56,6 +52,24 @@ export {
5652
* where the patch is indistinguishable from what the read path served — an
5753
* absent key, or a URL that matches the stored URL once redacted. Anything the
5854
* author actually changed wins, including clearing a URL's password by hand.
55+
* A patch whose CONTAINER for a nested leaf is removed is the author's word
56+
* too (they deleted the block), so nothing is grafted there.
57+
*
58+
* ## Derived from the redactor, not restated beside it
59+
*
60+
* This function used to mirror the read path rule by rule — one loop per
61+
* redaction source, each a copy that could silently fall behind (the docblock
62+
* threat on every one of them: "a redaction the restore side did not mirror
63+
* turns an untouched Save into silent credential deletion"). The nested-
64+
* position fix made the read path recursive, which would have added two more
65+
* loops — so the mirroring is now structural instead: compute what the read
66+
* path SERVES for the stored row (`redactDatasourceConfig(driver, stored)`),
67+
* and for every redacted path graft the stored value back exactly where the
68+
* patch still matches the served projection. A future redaction source is
69+
* mirrored here by construction, with nothing to forget. (Same inversion the
70+
* metadata door's generic `carryForwardRedactedValues` performs; this one
71+
* consumes the redactor's exact `redactedPaths` segments, so a stored key
72+
* with a literal dot cannot be mis-split.)
5973
*
6074
* What this does NOT do is let a patch set a refused key: `assertValidConfig`
6175
* still runs on the merged record, so a caller that types `password` into the
@@ -69,43 +83,25 @@ export function restoreRedactedConfig(
6983
if (!patch || typeof patch !== 'object') return patch;
7084
if (!stored || typeof stored !== 'object') return patch;
7185

72-
const hidden = new Set(redactableConfigKeys(driver));
86+
const served = redactDatasourceConfig(driver, stored);
7387
const out: Record<string, unknown> = { ...patch };
7488

75-
for (const key of hidden) {
76-
// Only when the patch does not speak to the key at all. A patch that DOES
77-
// carry it is the author's word, and (for a refused spelling) is about to
78-
// be refused on its own merits rather than quietly overwritten here.
79-
if (!(key in out) && stored[key] !== undefined) out[key] = stored[key];
80-
}
81-
82-
for (const [key, storedValue] of Object.entries(stored)) {
83-
if (hidden.has(key) || typeof storedValue !== 'string') continue;
84-
// The SAME composite the read path applies (userinfo password + #8337
85-
// credential query parameters) — a redaction this compare did not mirror
86-
// would make the untouched "Save" it exists for delete the credential.
87-
const redactedStored = redactUrlCredentials(storedValue);
88-
// Unchanged by redaction ⇒ it carried no credential ⇒ nothing to restore.
89-
if (redactedStored === storedValue) continue;
90-
if (out[key] === redactedStored) out[key] = storedValue;
91-
}
92-
93-
// The passthrough spellings (#9040) — the nested material the read path
94-
// drops by PATH (`options.auth.password`, `options.proxyPassword`, …). The
95-
// same narrow rule as the top-level keys, translated per leaf: restore ONLY
96-
// when the patch's container for the leaf exists but does not speak to the
97-
// leaf at all — exactly what the read path served. A patch carrying the leaf
98-
// is the author's word (a typed-in `auth.password` is then refused by the
99-
// #9040 write gate on its own merits); a patch with the CONTAINER removed is
100-
// the author's word too (they deleted the block), so nothing is grafted.
101-
for (const path of passthroughSecretPaths(driver)) {
89+
for (const path of served.redactedPaths) {
10290
const storedLeaf = valueAt(stored, path);
10391
if (storedLeaf === undefined) continue;
10492
const parentPath = path.slice(0, -1);
10593
const leafKey = path[path.length - 1] as string;
106-
const patchParent = valueAt(out, parentPath);
94+
const patchParent = parentPath.length === 0 ? out : valueAt(out, parentPath);
10795
if (!patchParent || typeof patchParent !== 'object' || Array.isArray(patchParent)) continue;
108-
if (leafKey in (patchParent as Record<string, unknown>)) continue;
96+
// What the read path served at this position: `undefined` for a dropped
97+
// key, the rewritten string for a URL redaction. The patch speaks for the
98+
// author exactly where it DIFFERS from that projection.
99+
const servedParent = parentPath.length === 0 ? served.config : valueAt(served.config, parentPath);
100+
const servedLeaf =
101+
servedParent && typeof servedParent === 'object' && !Array.isArray(servedParent)
102+
? (servedParent as Record<string, unknown>)[leafKey]
103+
: undefined;
104+
if ((patchParent as Record<string, unknown>)[leafKey] !== servedLeaf) continue;
109105
graftAt(out, path, storedLeaf);
110106
}
111107

packages/spec/api-surface/data.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@
5656
"BulkWriteHookPhase (type)",
5757
"BulkWritePayloadScope (type)",
5858
"CALENDAR_DATE_TYPES (const)",
59+
"CANONICAL_CREDENTIAL_KEYS (const)",
5960
"CLOCK_TIME_TYPES (const)",
6061
"COMPUTED_VALUE_TYPES (const)",
6162
"CONTEXT_TOKENS (const)",
6263
"CONTEXT_TOKEN_DESCRIPTIONS (const)",
6364
"CONTEXT_TOKEN_SUGGESTIONS (const)",
6465
"CONTEXT_TOKEN_WRAPPED_RE (const)",
66+
"CREDENTIAL_KEY_SPELLINGS (const)",
6567
"CREDENTIAL_URL_QUERY_PARAMS (const)",
6668
"CREDENTIAL_URL_QUERY_PARAM_NAMES (const)",
6769
"CalendarDateValue (type)",
@@ -258,6 +260,7 @@
258260
"FILTER_TEXT_CASES (const)",
259261
"FILTER_TEXT_ROWS (const)",
260262
"FILTER_TOKEN_WRAPPED_RE (const)",
263+
"FORMER_CREDENTIAL_ALIASES (const)",
261264
"FeedFilterMode (type)",
262265
"FeedItemType (type)",
263266
"Field (type)",
@@ -452,6 +455,7 @@
452455
"PAGINATION_UNORDERED_CASES (const)",
453456
"PAGINATION_ZERO_LIMIT_CASES (const)",
454457
"PASSTHROUGH_INLINE_CREDENTIAL_REFUSED (const)",
458+
"PASSTHROUGH_NESTED_CREDENTIAL_REFUSED (const)",
455459
"PaginationConformanceCase (interface)",
456460
"PaginationConformanceRow (interface)",
457461
"PerOperationRequiredPermissions (type)",
@@ -750,6 +754,8 @@
750754
"referenceTargetOf (function)",
751755
"referencedFields (function)",
752756
"refusedCredentialKeys (function)",
757+
"refusedCredentialPaths (function)",
758+
"refusedCredentialPathsOfSchema (function)",
753759
"refusedInlineCredentialKey (function)",
754760
"refusedPassthroughSecretPaths (function)",
755761
"renderAutonumber (function)",

packages/spec/export-origins/data.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@
5656
"BulkWriteHookPhase": "src/data/bulk-write-hook-conformance.ts#BulkWriteHookPhase (type)",
5757
"BulkWritePayloadScope": "src/data/bulk-write-hook-conformance.ts#BulkWritePayloadScope (type)",
5858
"CALENDAR_DATE_TYPES": "src/data/field-value.zod.ts#CALENDAR_DATE_TYPES (const)",
59+
"CANONICAL_CREDENTIAL_KEYS": "src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (const)",
5960
"CLOCK_TIME_TYPES": "src/data/field-value.zod.ts#CLOCK_TIME_TYPES (const)",
6061
"COMPUTED_VALUE_TYPES": "src/data/field-value.zod.ts#COMPUTED_VALUE_TYPES (const)",
6162
"CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#CONTEXT_TOKENS (const)",
6263
"CONTEXT_TOKEN_DESCRIPTIONS": "src/data/context-tokens.zod.ts#CONTEXT_TOKEN_DESCRIPTIONS (const)",
6364
"CONTEXT_TOKEN_SUGGESTIONS": "src/data/context-tokens.zod.ts#CONTEXT_TOKEN_SUGGESTIONS (const)",
6465
"CONTEXT_TOKEN_WRAPPED_RE": "src/data/context-tokens.zod.ts#CONTEXT_TOKEN_WRAPPED_RE (const)",
66+
"CREDENTIAL_KEY_SPELLINGS": "src/data/driver/common.zod.ts#CREDENTIAL_KEY_SPELLINGS (const)",
6567
"CREDENTIAL_URL_QUERY_PARAMS": "src/data/driver/common.zod.ts#CREDENTIAL_URL_QUERY_PARAMS (const)",
6668
"CREDENTIAL_URL_QUERY_PARAM_NAMES": "src/data/driver/common.zod.ts#CREDENTIAL_URL_QUERY_PARAM_NAMES (const)",
6769
"CalendarDateValue": "src/data/field-value.zod.ts#CalendarDateValue (type)",
@@ -258,6 +260,7 @@
258260
"FILTER_TEXT_CASES": "src/data/filter-text-conformance.ts#FILTER_TEXT_CASES (const)",
259261
"FILTER_TEXT_ROWS": "src/data/filter-text-conformance.ts#FILTER_TEXT_ROWS (const)",
260262
"FILTER_TOKEN_WRAPPED_RE": "src/data/context-tokens.zod.ts#FILTER_TOKEN_WRAPPED_RE (const)",
263+
"FORMER_CREDENTIAL_ALIASES": "src/data/driver/common.zod.ts#FORMER_CREDENTIAL_ALIASES (const)",
261264
"FeedFilterMode": "src/data/feed.zod.ts#FeedFilterMode (type)",
262265
"FeedItemType": "src/data/feed.zod.ts#FeedItemType (type)",
263266
"Field": "src/data/field.zod.ts#Field (type)",
@@ -452,6 +455,7 @@
452455
"PAGINATION_UNORDERED_CASES": "src/data/pagination-conformance.ts#PAGINATION_UNORDERED_CASES (const)",
453456
"PAGINATION_ZERO_LIMIT_CASES": "src/data/pagination-conformance.ts#PAGINATION_ZERO_LIMIT_CASES (const)",
454457
"PASSTHROUGH_INLINE_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#PASSTHROUGH_INLINE_CREDENTIAL_REFUSED (const)",
458+
"PASSTHROUGH_NESTED_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#PASSTHROUGH_NESTED_CREDENTIAL_REFUSED (const)",
455459
"PaginationConformanceCase": "src/data/pagination-conformance.ts#PaginationConformanceCase (interface)",
456460
"PaginationConformanceRow": "src/data/pagination-conformance.ts#PaginationConformanceRow (interface)",
457461
"PerOperationRequiredPermissions": "src/data/object.zod.ts#PerOperationRequiredPermissions (type)",
@@ -750,6 +754,8 @@
750754
"referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)",
751755
"referencedFields": "src/data/autonumber-format.ts#referencedFields (function)",
752756
"refusedCredentialKeys": "src/data/datasource-credential-redaction.ts#refusedCredentialKeys (function)",
757+
"refusedCredentialPaths": "src/data/datasource-credential-redaction.ts#refusedCredentialPaths (function)",
758+
"refusedCredentialPathsOfSchema": "src/data/datasource-credential-redaction.ts#refusedCredentialPathsOfSchema (function)",
753759
"refusedInlineCredentialKey": "src/data/driver/common.zod.ts#refusedInlineCredentialKey (function)",
754760
"refusedPassthroughSecretPaths": "src/data/datasource-credential-redaction.ts#refusedPassthroughSecretPaths (function)",
755761
"renderAutonumber": "src/data/autonumber-format.ts#renderAutonumber (function)",

0 commit comments

Comments
 (0)