Skip to content

Commit 688a28b

Browse files
os-zhuangclaude
andcommitted
feat(datasource): fail-closed credential resolution at connect (ADR-0062 Phase 2, D3)
A declared external.credentialsRef MUST resolve to a cleartext secret before the driver is built — an absent secret store or an unresolvable/undecryptable ref now fails closed (clear message, datasource left unconnected) instead of silently building a driver without the credential. Follows the same fail-fast (declared external + onMismatch:fail) vs degrade policy as connect failures. Converges with the runtime-admin secret path (same SecretBinder threaded through the shared connection service). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 68a8318 commit 688a28b

4 files changed

Lines changed: 174 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/service-datasource": minor
3+
---
4+
5+
feat(datasource): fail-closed credential resolution at connect (ADR-0062 Phase 2, D3)
6+
7+
`DatasourceConnectionService` now treats a declared `external.credentialsRef` as
8+
**fail-closed**: the credential must resolve to a cleartext secret (via the
9+
host's `SecretBinder` over `ICryptoProvider`) *before* the driver is built. An
10+
absent secret store, or a ref that cannot be resolved/decrypted (missing
11+
`sys_secret` row, rotated key, or a throwing resolver), leaves the datasource
12+
**unconnected with a clear message** — never a silent build-without-secret that
13+
would connect with no/wrong auth or fail later with a confusing driver error.
14+
15+
The same policy as connect failures applies: a code-defined `external` datasource
16+
with `validation.onMismatch: 'fail'` auto-connected at boot fails fast (bricks
17+
boot); runtime-admin create/update + boot rehydration degrade-with-warning. Code-
18+
and runtime-origin secrets converge on the one connection path (the same
19+
`SecretBinder` is threaded through the shared service). New `failed-credentials`
20+
connect status.

packages/runtime/src/datasource-autoconnect.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,55 @@ describe('ADR-0062 declared-datasource auto-connect', () => {
131131
});
132132
});
133133

134+
describe('ADR-0062 credentials fail-closed (D3)', () => {
135+
// An external datasource that declares a credentialsRef the host cannot
136+
// resolve (no matching sys_secret row) must FAIL CLOSED — never connect with
137+
// a missing credential. With onMismatch:'fail' that bricks boot (fail-fast).
138+
function credArtifact() {
139+
return {
140+
manifest: { id: 'com.test.ds-cred', name: 'DS Cred', version: '1.0.0' },
141+
objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }],
142+
datasources: [
143+
{
144+
name: 'needs_secret',
145+
driver: 'memory',
146+
schemaMode: 'external',
147+
origin: 'code',
148+
config: {},
149+
external: {
150+
allowWrites: false,
151+
credentialsRef: 'sys_secret:does-not-exist',
152+
validation: { onMismatch: 'fail', checkOnBoot: false },
153+
},
154+
active: true,
155+
},
156+
],
157+
};
158+
}
159+
160+
it('bricks boot with a clear message when a required credential cannot be resolved', async () => {
161+
const { ObjectQLPlugin } = await import('@objectstack/objectql');
162+
const { InMemoryDriver } = await import('@objectstack/driver-memory');
163+
const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import(
164+
'@objectstack/service-datasource'
165+
);
166+
const runtime = new Runtime({ cluster: false });
167+
const kernel = runtime.getKernel();
168+
await kernel.use(new DriverPlugin(new InMemoryDriver()));
169+
await kernel.use(new ObjectQLPlugin());
170+
await kernel.use(new AppPlugin(credArtifact()));
171+
await kernel.use(
172+
new DatasourceAdminServicePlugin({
173+
driverFactory: createDefaultDatasourceDriverFactory(),
174+
// A binder whose resolve never finds the secret (rotated key / missing row).
175+
secrets: { bind: async () => 'sys_secret:x', resolve: async () => undefined },
176+
}),
177+
);
178+
await expect(kernel.bootstrap()).rejects.toThrow(/needs_secret|credential|fail-fast/i);
179+
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
180+
}, BOOT_TIMEOUT);
181+
});
182+
134183
describe('ADR-0062 connect policy seam', () => {
135184
it('a deny policy leaves the external datasource unconnected (cloud egress isolation)', async () => {
136185
const denyExternal: DatasourceConnectPolicy = {

packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,68 @@ describe('DatasourceConnectionService.connect', () => {
197197
});
198198
});
199199

200+
describe('D3 credential resolution — fail-closed', () => {
201+
const credExternal: ConnectableDatasource = {
202+
name: 'warehouse',
203+
driver: 'sqlite',
204+
schemaMode: 'external',
205+
config: {},
206+
external: { allowWrites: false, credentialsRef: 'sys_secret:abc', validation: { onMismatch: 'warn' } },
207+
};
208+
209+
it('fails closed when a credentialsRef is declared but NO secret store is configured', async () => {
210+
const factory = fakeFactory();
211+
const { service, engine } = svc({ factory }); // no `secrets`
212+
const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
213+
expect(result.status).toBe('failed-credentials');
214+
expect(result.reason).toMatch(/no secret store/);
215+
expect(factory.create).not.toHaveBeenCalled(); // never built without the secret
216+
expect(engine!.drivers.size).toBe(0);
217+
});
218+
219+
it('fails closed when the credentialsRef cannot be resolved/decrypted (undefined)', async () => {
220+
const factory = fakeFactory();
221+
const { service } = svc({ factory, secrets: { resolve: async () => undefined } });
222+
const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
223+
expect(result.status).toBe('failed-credentials');
224+
expect(result.reason).toMatch(/could not be resolved or decrypted/);
225+
expect(factory.create).not.toHaveBeenCalled();
226+
});
227+
228+
it('fails closed when the resolver throws', async () => {
229+
const factory = fakeFactory();
230+
const { service } = svc({
231+
factory,
232+
secrets: {
233+
resolve: async () => {
234+
throw new Error('kms unreachable');
235+
},
236+
},
237+
});
238+
const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
239+
expect(result.status).toBe('failed-credentials');
240+
expect(factory.create).not.toHaveBeenCalled();
241+
});
242+
243+
it('fail-fast: a declared-auto external + onMismatch:fail with an unresolvable credential re-throws', async () => {
244+
const failCred: ConnectableDatasource = {
245+
...credExternal,
246+
external: { allowWrites: false, credentialsRef: 'sys_secret:abc', validation: { onMismatch: 'fail' } },
247+
};
248+
const { service } = svc({ secrets: { resolve: async () => undefined } });
249+
await expect(service.connect(failCred, { context: { trigger: 'declared-auto' } })).rejects.toThrow(/fail-fast/);
250+
});
251+
252+
it('connects when the credential resolves to a secret', async () => {
253+
const create = vi.fn(async () => ({ driver: { name: 'd' }, connect: async () => {} }));
254+
const factory: IDatasourceDriverFactory = { supports: () => true, create };
255+
const { service } = svc({ factory, secrets: { resolve: async () => 's3cr3t' } });
256+
const result = await service.connect(credExternal);
257+
expect(result.status).toBe('connected');
258+
expect(create).toHaveBeenCalledWith(expect.objectContaining({ secret: 's3cr3t' }));
259+
});
260+
});
261+
200262
describe('DatasourceConnectionService.connectDeclared', () => {
201263
it('connects only the gated datasources and syncs each one’s bound objects', async () => {
202264
const { service, engine, factory } = svc();

packages/services/service-datasource/src/datasource-connection-service.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export type ConnectStatus =
106106
| 'skipped-policy'
107107
| 'skipped-no-infra'
108108
| 'skipped-unsupported'
109+
| 'failed-credentials'
109110
| 'failed-degraded';
110111

111112
export interface ConnectResult {
@@ -233,9 +234,42 @@ export class DatasourceConnectionService {
233234
);
234235
}
235236

237+
// Credential resolution (ADR-0062 D3) — FAIL-CLOSED, and done *before* the
238+
// build try-block so a fail-fast verdict propagates (rather than being
239+
// swallowed and re-classified by the catch below). A declared
240+
// `external.credentialsRef` MUST resolve to a cleartext secret before we
241+
// open a connection: building a driver without it would silently connect
242+
// with no/wrong auth (or fail later with a confusing driver error). So an
243+
// absent secret store, or an unresolvable/undecryptable ref, leaves the
244+
// datasource unconnected with a clear message — never a silent skip.
245+
let secret: string | undefined;
246+
const credentialsRef = record.external?.credentialsRef;
247+
if (credentialsRef) {
248+
const resolver = this.cfg.secrets?.resolve;
249+
if (!resolver) {
250+
return this.handleFailure(
251+
record,
252+
'failed-credentials',
253+
`requires credential '${credentialsRef}' but no secret store (SecretBinder/ICryptoProvider) is configured`,
254+
opts.context,
255+
);
256+
}
257+
try {
258+
secret = await resolver(credentialsRef);
259+
} catch (err) {
260+
return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context);
261+
}
262+
if (secret == null || secret === '') {
263+
return this.handleFailure(
264+
record,
265+
'failed-credentials',
266+
`credential '${credentialsRef}' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)`,
267+
opts.context,
268+
);
269+
}
270+
}
271+
236272
try {
237-
const credentialsRef = record.external?.credentialsRef;
238-
const secret = credentialsRef ? await this.cfg.secrets?.resolve?.(credentialsRef) : undefined;
239273
const handle = await factory.create({ ...toSpec(record), ...(secret ? { secret } : {}) });
240274
if (typeof handle?.connect === 'function') await handle.connect();
241275

@@ -285,11 +319,13 @@ export class DatasourceConnectionService {
285319
}
286320

287321
/**
288-
* Apply the D5 connect-failure policy. A code-defined `external` datasource
289-
* with `onMismatch:'fail'` auto-connected at boot re-throws (fail-fast,
290-
* bricking boot as intended). Runtime-admin create/update + boot rehydration
291-
* always degrade-with-warning — a UI action or a replica blip must never
292-
* brick the running server (preserves the pre-ADR-0062 admin behavior).
322+
* Apply the D5 connect-failure policy (also covers D3 credential failures). A
323+
* code-defined `external` datasource with `onMismatch:'fail'` auto-connected at
324+
* boot re-throws (fail-fast, bricking boot as intended). Runtime-admin
325+
* create/update + boot rehydration always degrade-with-warning — a UI action
326+
* or a replica blip must never brick the running server (preserves the
327+
* pre-ADR-0062 admin behavior). Either way the datasource is left unconnected
328+
* with a clear message — never a silent skip.
293329
*/
294330
private handleFailure(
295331
record: ConnectableDatasource,

0 commit comments

Comments
 (0)