Skip to content

Commit f3d84b3

Browse files
fix(service-datasource)!: refuse a top-level password or connectionString at the datasource admin write doors (#28607)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCtDq7uGdxAmnoCoqT9Rx4h)_ Fixes #28549 Clause-②: no (narrowing) — a create or edit body that carries `password` or `connectionString` at its top level, which `POST /api/v1/datasources` answered 201 (and `PATCH /api/v1/datasources/NAME` 200) with the key silently dropped, is refused `400 DATASOURCE_ADMIN_ERROR` before anything is written. Nothing is widened. Metadata-protocol: none Skills-docs: none — no page under `skills/**` or `content/docs` teaches the admin write body; the refusal text is `DatasourceSchema`'s own, which already ships with the spec and does not move. **BREAKING** accept-set narrowing at the datasource admin write doors (`@objectstack/service-datasource`). ## 维护者速读 - **改了什么**:数据源管理接口的创建(`POST /api/v1/datasources`)和编辑(`PATCH /api/v1/datasources/NAME`)现在会拒收写在请求体顶层的 `password` 或 `connectionString`:400 `DATASOURCE_ADMIN_ERROR`,点名这个键,不回显值,什么都不写入。拒收文字直接取自 `DatasourceSchema` 自己的报错,也就是 `os compile` 打印的那一段(password → 用秘密字段或 `external.credentialsRef` 绑定;connectionString → 放进 `config.url`)。 - **为什么改**:卡 #28549(分诊 6554155790):以前这两个键被悄悄丢掉、接口照样答 201,作者拿到一个连不上的数据源且没有任何解释,而构建门对同一个数据源会点名拒收。 - **风险与回滚**:只收窄这两个顶层键;用 `secret` 字段传凭据、编辑时保留已绑定的 `external.credentialsRef`、`config.password` 的原有拒收都不变。控制台表单只发 `label/driver/schemaMode/config/external/secret`,不会触发。回滚即 revert。 - **先看**:`packages/services/service-datasource/src/datasource-admin-service.ts`(新加的 `assertNoTopLevelCredential` 及其在 create / update 里的两处调用);`packages/services/service-datasource/src/__tests__/datasource-admin-top-level-credential.test.ts`;`.changeset/28549-datasource-top-level-credential.md`。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-admin-top-level-credential.test.ts` → 10/10 通过。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **The doors** (`packages/services/service-datasource/src/datasource-admin-service.ts`). A new private `assertNoTopLevelCredential(name, body)` runs at the start of both write doors, before anything is read from or written to the store: - `createDatasource`: right after the name rule and the draft `external.credentialsRef` refusal. - `updateDatasource`: right after the 404 and the code-defined refusal, before the merge. It runs on every patch, a `label`/`active`-only edit included: that exemption spares a row stored before a gate, while this judges a credential the author is writing now. It looks for the keys in `TOP_LEVEL_CREDENTIAL_KEYS` (`password`, `connectionString`) on the body. When one is present it asks `DatasourceSchema` for its refusal of those key names, with `null` standing in for every value, and keeps the root `unrecognized_keys` message. It then throws `Invalid datasource 'NAME'.` plus that message, the heading the existing record judgement (`assertValidRecord`) uses. The route turns the throw into `400 DATASOURCE_ADMIN_ERROR`, as it does for every refusal from this service. **One spelling.** The prescription is not copied anywhere. The message is the strict shape's own refusal from `packages/spec/src/data/datasource.zod.ts` (the `password` and `connectionString` guidance rows), the same text `os compile` prints. The pin asserts the door's message equals `Invalid datasource 'NAME'.` plus `DatasourceSchema`'s message derived inside the test, so a re-inlined copy that differs by a word goes red. **No credential stored or echoed.** Only key names reach the judgement; the values never do. The refusal is thrown before `writeSecret`, `putDatasourceRecord` or any pool seam runs. The tests read the store, the secret store and the op log afterwards, and check that the answer holds neither the placeholder password nor the DSN host. ## Tests New file `src/__tests__/datasource-admin-top-level-credential.test.ts`. It drives the real routes over `HonoHttpServer` with the real `DatasourceAdminService` on an in-memory record store and secret store, using the `entitled-caller.fixture` caller. Every credential is an obvious placeholder (`PLACEHOLDER_NOT_A_SECRET`, `db.example.invalid`). - **Refusals** (each asserts status 400 + `error.code` `DATASOURCE_ADMIN_ERROR` + `success: false`, the exact message, the value absent, and nothing stored, minted or registered): - POST with a top-level `password` (the card's repro 1); the message also names `external.credentialsRef`. - POST with a top-level `connectionString` carrying URL userinfo (repro 2). - POST with both keys: one answer naming both. - POST with a top-level `password` beside a `secret`. - PATCH with a top-level `password`, and with a top-level `connectionString`: the stored record is unchanged. - PATCH that is `label`-only plus a `password`. - **Controls**: - POST with the credential as `secret`: 201, a `sys_secret:` ref bound at `external.credentialsRef`, the placeholder held only in the secret store. - PATCH on a record with a bound `external.credentialsRef`: 200, ref kept. - POST with `config.password`: still refused with the driver contract's `Invalid configuration for driver 'postgres'.` naming `config.password`, unchanged. <details><summary>Red first, green, ablation</summary> - **Red first** (test written and run against the unchanged base service): `Tests 7 failed | 3 passed (10)`. The four POST refusals were `expected { status: 201, success: true, … } to deeply equal { status: 400, success: false, … }`, and the three PATCH refusals answered `status: 200`. The three controls were green. - **Green** at the fix: `Tests 10 passed (10)`. - **Ablation** (`node scripts/ablation-replace.mjs`, anchor ` if (present.length === 0) return;` → ` if (present.length >= 0) return;`, which turns the check off at both call sites): anchor 1 → 0, replacement 0 → 1, blob `fae370d6fc02` → `7e11b57f76be`. Result: the 7 refusal cases red again (POST back to 201, PATCH back to 200) and the 3 controls green. Restore: blob equals HEAD (`fae370d6fc02`), `git diff HEAD` empty. The test imports the service from `src/`, so no build sits between the mutation and the run. - `pnpm --filter @objectstack/service-datasource test`: Test Files 67 passed (67), Tests 1110 passed (1110). `pnpm --filter @objectstack/service-datasource typecheck` exit 0, and `tsc --listFiles` includes the new test file. </details> ## Acceptance notes - **Which keys.** The build door's strict shape refuses every undeclared top-level key. Its credential prescription ("must never be inlined … bind through `external.credentialsRef`") is given for `password`. `connectionString` gets "belongs inside `config` … `config: { url: … }`", and a `config.url` with userinfo is then refused by the driver contract, which points at the secret. The doors take each key's text verbatim from the schema, so `connectionString` answers with the `config.url` prescription, not the `credentialsRef` one. That is "the same prescription the build door gives" for that key. - **Other top-level keys these doors still drop silently** (read from `toRecord` and the update merge, which project only `name`, `label`, `driver`, `schemaMode`, `config`, `external`, `pool`, `active`): the other connection keys the schema has guidance for (`url`, `host`, `port`, `database`, `user`, `username`, `filename`), `connector`, the retired blocks (`readReplicas`, `capabilities`, `retryPolicy`, `healthCheck`, …), and any other unknown key. A top-level `url` can carry userinfo exactly as `connectionString` does. Out of scope by the order ("a general unknown-key refusal for every top-level key is out of scope"). A closed body would also have to tolerate what a client round-trips from `GET /datasources/NAME` (`origin`, `hasSecret`, `redactedConfigKeys`, `definedIn`). - **The unsaved-draft probe** (`POST /api/v1/datasources/test`, `testConnection`) projects through the same `toRecord` and still ignores a top-level `password`: the probe then fails to connect with the driver's own error, not a silent green. It is not a write door, so it is left as it was. - **Control premise.** The order's control "a valid body with `external.credentialsRef` still answers 201" does not hold on `main`: since the own-reference rule, create refuses a draft that names `external.credentialsRef`. The controls are a create whose credential arrives as `secret` and is bound at `external.credentialsRef` (201), and an edit that keeps a bound `external.credentialsRef` (200). - **Producers measured.** The Console's datasource editor (`ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx`, `draftBody`) sends `label`, `driver`, `schemaMode`, `config`, `external`, `secret` and never a top-level credential. `packages/client` exposes only the `datasources/NAME/external/*` family. ## Verification All readings on head `0cc981aa` (the only commit; nothing landed after the union ran). - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 61 families, each run with its exit code captured before any pipe (stdin from `/dev/null`). `--ran` reconciliation: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED`. Two first reads were container prerequisites, re-read after meeting them: - `check-plugin-teardown-shape.mjs --self-test` exit 3 → `git fetch --depth=1 origin 621a487…` → exit 0 (48 cases pass). - `check:dual-build-cjs-loads` exit 3 → full `pnpm build --concurrency=2` (117/117 tasks) → exit 0. - **Hand gates:** `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` exit 0; `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0 (no anchored file in the change set); `node scripts/check-nul-bytes.mjs` exit 0; `pnpm exec eslint --no-inline-config packages/services/service-datasource` exit 0, no findings. The repo-wide `pnpm lint` is CI's. - **Full dogfood** (owed by the `!` and the `(narrowing)` arm): after the `@objectstack/dogfood^...` build, under the lock, `pnpm --filter @objectstack/dogfood test` → Test Files 364 passed | 3 skipped (367), Tests 2934 passed | 28 skipped (2962); no failure. - **CI** families (Test Core incl. `test:repo`, the lint farm, the type-check lanes) are read off this PR's check-runs by the seat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5moUefysT3ba6SpdYY Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5da45b commit f3d84b3

3 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: `POST /api/v1/datasources` and `PATCH /api/v1/datasources/:name` refuse a top-level `password` or `connectionString` by name, with the build door's prescription, instead of dropping it and answering success
6+
7+
Clause-②: no (narrowing) — a create or edit body that carries `password` or `connectionString` at its top level, which answered 201 / 200 with the key silently dropped, is refused with `400 DATASOURCE_ADMIN_ERROR`. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the admin write doors now refuse two top-level keys that `DatasourceSchema` already refuses at the build door, with that schema's own message. A refused body was never stored (the key was dropped), so there is nothing at rest for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the datasource admin write doors (`@objectstack/service-datasource`).
13+
14+
- **Before.** The doors built the stored record from its known keys only, so a `password` or a `connectionString` written at the top level of the body vanished and the write answered success. The author got a datasource that could not connect and no explanation, while `os compile` refused the same datasource by name.
15+
- **Now.** Either key at the top level of a create or edit body is refused before anything is read or written, naming the key and never its value. The message is `DatasourceSchema`'s own refusal for that key, the one `os compile` prints: a `password` is bound through the connection form's secret field or `external.credentialsRef`; a `connectionString` belongs in `config` as `url`. An edit is refused even when it touches only `label` or `active`.
16+
- **Unchanged.** A credential sent as `secret` is still stored in the secret store and bound at `external.credentialsRef`. `config.password` keeps the driver contract's refusal. Other unknown top-level keys are still dropped as before.
17+
18+
**The remedy.** Send the credential as `secret` (the connection form's secret field), or move a connection string into `config.url` without userinfo.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { DatasourceSchema } from '@objectstack/spec/data';
5+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
6+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
7+
import {
8+
DatasourceAdminService,
9+
type DatasourceAdminServiceConfig,
10+
type StoredDatasource,
11+
} from '../datasource-admin-service.js';
12+
import {
13+
ENTITLED_CREDENTIAL,
14+
createSessionAuthService,
15+
createGrantsEngine,
16+
} from './entitled-caller.fixture.js';
17+
18+
/**
19+
* #28549 — a credential written at the TOP level of a datasource body is
20+
* refused by name at the admin write doors, as the build door refuses it.
21+
*
22+
* `os compile` judges a datasource against `DatasourceSchema`, whose strict
23+
* shape refuses a top-level `password` ("must never be inlined", bind it
24+
* through `external.credentialsRef`) and a top-level `connectionString`
25+
* ("belongs inside `config`"). `POST /api/v1/datasources` and
26+
* `PATCH /api/v1/datasources/:name` projected the body onto the record's known
27+
* keys first, so both keys vanished and the write answered success: the
28+
* author got a datasource that could not connect and no explanation.
29+
*
30+
* Driven through the REAL routes over the REAL service. Each refusal reads the
31+
* envelope (status + `error.code`), the message (the heading plus the spec's
32+
* own refusal for that key, derived here from `DatasourceSchema`, never
33+
* restated), the absence of the value from the answer, and the store behind
34+
* it: nothing stored, minted or registered.
35+
*
36+
* Every credential below is an obvious placeholder.
37+
*/
38+
39+
const authService = createSessionAuthService();
40+
const grantsEngine = createGrantsEngine();
41+
42+
const PLACEHOLDER_PASSWORD = 'PLACEHOLDER_NOT_A_SECRET';
43+
const PLACEHOLDER_DSN = `postgres://app:${PLACEHOLDER_PASSWORD}@db.example.invalid:5432/analytics`;
44+
45+
/** In-memory datasource store and secret store behind a real {@link DatasourceAdminService}. */
46+
function makeDoor(seed: StoredDatasource[] = []) {
47+
const records = new Map<string, StoredDatasource>(seed.map((r) => [r.name, structuredClone(r)]));
48+
const secrets = new Map<string, string>();
49+
let seq = 0;
50+
const ops: string[] = [];
51+
52+
const config: DatasourceAdminServiceConfig = {
53+
probe: async () => ({ ok: true, latencyMs: 1 }),
54+
listDatasourceRecords: async () => [...records.values()].map((r) => structuredClone(r)),
55+
getDatasourceRecord: async (name) => {
56+
const r = records.get(name);
57+
return r ? structuredClone(r) : undefined;
58+
},
59+
putDatasourceRecord: async (record) => {
60+
ops.push(`put:${record.name}`);
61+
records.set(record.name, structuredClone(record));
62+
},
63+
deleteDatasourceRecord: async (name) => {
64+
records.delete(name);
65+
},
66+
writeSecret: async (input, hint) => {
67+
ops.push('writeSecret');
68+
const ref = `sys_secret:sec_${hint.name}_${++seq}`;
69+
secrets.set(ref, input.value);
70+
return ref;
71+
},
72+
removeSecret: async (ref) => {
73+
ops.push('removeSecret');
74+
secrets.delete(ref);
75+
},
76+
countBoundObjects: async () => 0,
77+
listObjectsBoundTo: async () => [],
78+
registerPool: () => {
79+
ops.push('registerPool');
80+
},
81+
unregisterPool: () => {
82+
ops.push('unregisterPool');
83+
},
84+
reregisterPool: () => {
85+
ops.push('reregisterPool');
86+
},
87+
};
88+
89+
const service = new DatasourceAdminService(config);
90+
const server = new HonoHttpServer(0);
91+
const ctx = {
92+
getService: vi.fn((name: string) =>
93+
name === 'auth'
94+
? authService
95+
: name === 'objectql' || name === 'data'
96+
? grantsEngine
97+
: name === 'datasource-admin'
98+
? service
99+
: undefined,
100+
),
101+
} as any;
102+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
103+
const app = server.getRawApp();
104+
105+
const send = (method: string, path: string, body: unknown) =>
106+
app.fetch(
107+
new Request(`http://local/api/v1${path}`, {
108+
method,
109+
headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL },
110+
body: JSON.stringify(body),
111+
}),
112+
);
113+
114+
return { send, records, secrets, ops };
115+
}
116+
117+
/**
118+
* The build door's refusal of `keys` at the top level of a datasource:
119+
* `DatasourceSchema`'s own `unrecognized_keys` message. Throws when the spec
120+
* makes no such refusal, so a pin can never pass on a premise the spec no
121+
* longer holds.
122+
*/
123+
function specRefusal(keys: string[]): string {
124+
const result = DatasourceSchema.safeParse(Object.fromEntries(keys.map((k) => [k, null])));
125+
const issue = result.success
126+
? undefined
127+
: result.error.issues.find((i) => i.code === 'unrecognized_keys' && i.path.length === 0);
128+
if (!issue) throw new Error(`fixture premise broken: DatasourceSchema does not refuse ${keys.join(', ')}`);
129+
return issue.message;
130+
}
131+
132+
async function expectRefusal(res: Response): Promise<string> {
133+
const body = (await res.json()) as { success: boolean; error?: { code: string; message: string } };
134+
expect({ status: res.status, success: body.success, code: body.error?.code }).toEqual({
135+
status: 400,
136+
success: false,
137+
code: 'DATASOURCE_ADMIN_ERROR',
138+
});
139+
return body.error!.message;
140+
}
141+
142+
const BASE = { driver: 'postgres', config: { host: 'db.example.invalid', database: 'analytics' } };
143+
const STORED: StoredDatasource = { name: 'wh', ...BASE, origin: 'runtime' };
144+
145+
describe('#28549 — POST /api/v1/datasources refuses a top-level credential by name', () => {
146+
it('refuses a top-level `password` with the build door\'s prescription, storing nothing', async () => {
147+
const { send, records, secrets, ops } = makeDoor();
148+
149+
const message = await expectRefusal(
150+
await send('POST', '/datasources', { name: 'r7p_top', ...BASE, password: PLACEHOLDER_PASSWORD }),
151+
);
152+
153+
expect(message).toBe(`Invalid datasource 'r7p_top'.\n${specRefusal(['password'])}`);
154+
expect(message).toContain('`password`');
155+
expect(message).toContain('external.credentialsRef');
156+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
157+
expect(records.has('r7p_top')).toBe(false);
158+
expect(secrets.size).toBe(0);
159+
expect(ops).toEqual([]);
160+
});
161+
162+
it('refuses a top-level `connectionString` carrying URL userinfo, storing nothing', async () => {
163+
const { send, records, secrets, ops } = makeDoor();
164+
165+
const message = await expectRefusal(
166+
await send('POST', '/datasources', { name: 'r7p_top2', ...BASE, connectionString: PLACEHOLDER_DSN }),
167+
);
168+
169+
expect(message).toBe(`Invalid datasource 'r7p_top2'.\n${specRefusal(['connectionString'])}`);
170+
expect(message).toContain('`connectionString`');
171+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
172+
expect(message).not.toContain('db.example.invalid');
173+
expect(records.has('r7p_top2')).toBe(false);
174+
expect(secrets.size).toBe(0);
175+
expect(ops).toEqual([]);
176+
});
177+
178+
it('names both keys in one answer when both are written', async () => {
179+
const { send, records } = makeDoor();
180+
181+
const message = await expectRefusal(
182+
await send('POST', '/datasources', {
183+
name: 'r7p_both',
184+
...BASE,
185+
password: PLACEHOLDER_PASSWORD,
186+
connectionString: PLACEHOLDER_DSN,
187+
}),
188+
);
189+
190+
expect(message).toBe(`Invalid datasource 'r7p_both'.\n${specRefusal(['password', 'connectionString'])}`);
191+
expect(records.has('r7p_both')).toBe(false);
192+
});
193+
194+
it('refuses it beside a supplied `secret` too: the secret field is the one credential channel', async () => {
195+
const { send, records, secrets, ops } = makeDoor();
196+
197+
await expectRefusal(
198+
await send('POST', '/datasources', {
199+
name: 'r7p_secret',
200+
...BASE,
201+
password: PLACEHOLDER_PASSWORD,
202+
secret: PLACEHOLDER_PASSWORD,
203+
}),
204+
);
205+
206+
expect(records.has('r7p_secret')).toBe(false);
207+
expect(secrets.size).toBe(0);
208+
expect(ops).toEqual([]);
209+
});
210+
});
211+
212+
describe('#28549 — PATCH /api/v1/datasources/:name refuses a top-level credential by name', () => {
213+
it.each([
214+
['password', { password: PLACEHOLDER_PASSWORD }],
215+
['connectionString', { connectionString: PLACEHOLDER_DSN }],
216+
])('refuses a top-level `%s`, leaving the stored record as it was', async (key, extra) => {
217+
const { send, records, ops } = makeDoor([STORED]);
218+
219+
const message = await expectRefusal(await send('PATCH', '/datasources/wh', { config: BASE.config, ...extra }));
220+
221+
expect(message).toBe(`Invalid datasource 'wh'.\n${specRefusal([key])}`);
222+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
223+
expect(records.get('wh')).toEqual(STORED);
224+
expect(ops).toEqual([]);
225+
});
226+
227+
it('refuses it on a `label`-only edit too: the exemption covers a stored row, not a credential in the body', async () => {
228+
const { send, records, ops } = makeDoor([STORED]);
229+
230+
await expectRefusal(await send('PATCH', '/datasources/wh', { label: 'Warehouse', password: PLACEHOLDER_PASSWORD }));
231+
232+
expect(records.get('wh')).toEqual(STORED);
233+
expect(ops).toEqual([]);
234+
});
235+
});
236+
237+
describe('#28549 — controls: the doors keep their other answers', () => {
238+
it('still creates a datasource whose credential arrives as `secret`, bound at `external.credentialsRef`', async () => {
239+
const { send, records, secrets } = makeDoor();
240+
241+
const res = await send('POST', '/datasources', { name: 'wh_ok', ...BASE, secret: PLACEHOLDER_PASSWORD });
242+
243+
expect(res.status).toBe(201);
244+
const ref = records.get('wh_ok')?.external?.credentialsRef;
245+
expect(ref).toMatch(/^sys_secret:/);
246+
expect(secrets.get(ref!)).toBe(PLACEHOLDER_PASSWORD);
247+
expect(JSON.stringify(records.get('wh_ok'))).not.toContain(PLACEHOLDER_PASSWORD);
248+
});
249+
250+
it('still answers an edit that keeps the bound `external.credentialsRef`', async () => {
251+
const bound: StoredDatasource = { ...STORED, external: { credentialsRef: 'sys_secret:sec_wh_own' } };
252+
const { send, records } = makeDoor([bound]);
253+
254+
const res = await send('PATCH', '/datasources/wh', { label: 'Warehouse' });
255+
256+
expect(res.status).toBe(200);
257+
expect(records.get('wh')?.external?.credentialsRef).toBe('sys_secret:sec_wh_own');
258+
});
259+
260+
it('keeps the driver contract\'s refusal of `config.password`', async () => {
261+
const { send, records, ops } = makeDoor();
262+
263+
const message = await expectRefusal(
264+
await send('POST', '/datasources', {
265+
name: 'wh_cfg',
266+
...BASE,
267+
config: { ...BASE.config, password: PLACEHOLDER_PASSWORD },
268+
}),
269+
);
270+
271+
expect(message.split('\n')[0]).toBe("Invalid configuration for driver 'postgres'.");
272+
expect(message).toContain('config.password');
273+
expect(message).not.toContain(PLACEHOLDER_PASSWORD);
274+
expect(records.has('wh_cfg')).toBe(false);
275+
expect(ops).toEqual([]);
276+
});
277+
});

‎packages/services/service-datasource/src/datasource-admin-service.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ const PROBE_DRAFT_NAME = 'connection_test_draft';
8686
*/
8787
const RECORD_JUDGED_PATCH_KEYS = ['driver', 'schemaMode', 'config', 'external', 'pool'] as const;
8888

89+
/**
90+
* [#28549] The top-level body keys a credential arrives under that the record
91+
* projection ({@link DatasourceAdminService.toRecord}, the update merge) would
92+
* otherwise drop without a word: a `password`, and a `connectionString` that
93+
* can carry one as URL userinfo. `DatasourceSchema` refuses both at the build
94+
* door; these doors judge exactly these keys through it. Not every key the
95+
* projection drops: those carry no credential, and a closed body at these
96+
* doors is a change of its own.
97+
*/
98+
const TOP_LEVEL_CREDENTIAL_KEYS = ['password', 'connectionString'] as const;
99+
89100
/** A copy of `record` carrying the binding a supplied secret is about to create. */
90101
function withPendingSecretRef(record: StoredDatasource): StoredDatasource {
91102
return { ...record, external: { ...(record.external ?? {}), credentialsRef: PENDING_SECRET_REF } };
@@ -667,6 +678,7 @@ export class DatasourceAdminService implements IDatasourceAdminService {
667678
async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary> {
668679
this.assertValidName(input?.name);
669680
this.assertNoDraftCredentialsRef(input);
681+
this.assertNoTopLevelCredential(input.name, input);
670682
if (!input.driver) throw new Error('A driver is required to create a datasource.');
671683
this.assertValidConfig(input.driver, input.config);
672684
// The wizard is a publish door for `pool` too (#5714). Rejected BEFORE the
@@ -728,6 +740,10 @@ export class DatasourceAdminService implements IDatasourceAdminService {
728740
if (existing.origin !== 'runtime') {
729741
throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);
730742
}
743+
// [#28549] Whatever else the patch touches, a `label`/`active`-only edit
744+
// included: that exemption spares a row stored before a gate, and this
745+
// judges a credential the author is writing now.
746+
this.assertNoTopLevelCredential(existing.name, patch);
731747

732748
// Merge patch over the existing record; `name`/`origin` are never patched.
733749
const merged: StoredDatasource = {
@@ -1244,6 +1260,35 @@ export class DatasourceAdminService implements IDatasourceAdminService {
12441260
);
12451261
}
12461262

1263+
/**
1264+
* [#28549] Refuse a body that writes a credential at the top level
1265+
* ({@link TOP_LEVEL_CREDENTIAL_KEYS}). The record is built from the known
1266+
* keys only, so such a key would vanish and the write answer success over a
1267+
* datasource that cannot connect, while the build door refuses the same
1268+
* datasource by name.
1269+
*
1270+
* The verdict and its prescription are `DatasourceSchema`'s own refusal of
1271+
* those keys, the one `os compile` prints, under the heading
1272+
* {@link assertValidRecord} gives. Only the key names are judged, never the
1273+
* values, so the answer names the key and cannot echo the credential. ⛔ No
1274+
* prescription is restated here; the spec is the one copy. Thrown before
1275+
* anything is read or written (a `400` at the route).
1276+
*/
1277+
private assertNoTopLevelCredential(name: string, body: object): void {
1278+
const present = TOP_LEVEL_CREDENTIAL_KEYS.filter(
1279+
(key) => (body as Record<string, unknown>)[key] !== undefined,
1280+
);
1281+
if (present.length === 0) return;
1282+
const result = DatasourceSchema.safeParse(Object.fromEntries(present.map((key) => [key, null])));
1283+
const detail = result.success
1284+
? []
1285+
: result.error.issues
1286+
.filter((issue) => issue.code === 'unrecognized_keys' && issue.path.length === 0)
1287+
.map((issue) => issue.message);
1288+
if (detail.length === 0) return;
1289+
throw new Error(`Invalid datasource '${name}'.\n${detail.join('\n')}`);
1290+
}
1291+
12471292
private toRecord(input: DatasourceDraft): StoredDatasource {
12481293
return {
12491294
name: input.name,

0 commit comments

Comments
 (0)