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
10 changes: 10 additions & 0 deletions .changeset/29069-migrate-credential-empty-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/service-datasource': patch
---

fix(service-datasource): the credential migration's drop-inline refusal names an empty stored credential as empty

Clause-②: no
Metadata-protocol: none

When a datasource already references a stored secret that decrypts to an empty value (a row sealed empty before the admin doors refused an empty `secret`), the credential migration still refuses to drop the inline copy, with the same `refused` status, but its reason now says the credential is empty, in the connect path's wording, instead of blaming a missing `sys_secret` row or a changed encryption key. A missing row or a decrypt failure keeps its sentence.
Original file line number Diff line number Diff line change
Expand Up @@ -631,11 +631,41 @@ describe('migrateCredential (#8155)', () => {
const result = await h.service.migrateCredential('warehouse');

expect(result.status).toBe('refused');
expect(result.reason).toContain('could not be resolved');
expect(result.reason).toBe(
"Datasource 'warehouse' already references a stored secret, but it could not be resolved or "
+ 'decrypted (a missing `sys_secret` row, or a changed encryption key). Removing the inline '
+ 'credential would leave this datasource with no working credential at all.',
);
expect(result.remedy).toContain('replaces the unresolvable reference');
// The inline copy is the only working credential left — it stays.
expect(h.store.get('warehouse')!.config?.password).toBe('hunter2');
});

it('refuses to drop the inline copy when the existing ref decrypts to an empty value, and says it is empty', async () => {
// A row sealed empty before the admin doors refused an empty `secret`
// resolves to ''. The outcome is the same refusal; the cause is named as
// an empty credential, in the connect path's wording, not blamed on a
// missing row or a changed key.
const h = makeHarness({
seed: [legacyRow({ external: { credentialsRef: 'sys_secret://sealed-empty' } })],
readBackOverride: async () => '',
});
const result = await h.service.migrateCredential('warehouse');

expect(result.status).toBe('refused');
expect(result.reason).toBe(
"Datasource 'warehouse' already references a stored secret, but credential "
+ "'sys_secret://sealed-empty' is empty (its sys_secret row holds an empty value). Removing the "
+ 'inline credential would leave this datasource with no working credential at all.',
);
expect(result.reason).not.toContain('could not be resolved');
expect(result.reason).not.toContain('encryption key');
expect(result.remedy).toContain("set the datasource's secret again");
// Same outcome as the missing-row branch: nothing written, inline copy kept.
expect(h.store.get('warehouse')!.config?.password).toBe('hunter2');
expect(h.ops).toEqual(['readSecret']);
});

it('refuses when the host has no readable secret store', async () => {
const h = makeHarness({ seed: [legacyRow()], noReadSecret: true });
const result = await h.service.migrateCredential('warehouse');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,22 @@ export class DatasourceAdminService implements IDatasourceAdminService {
// inline copy is the last cleartext copy, so it is dropped only once the
// existing ref is proved resolvable.
const resolved = await this.tryReadSecret(plan.credentialsRef);
if (resolved == null || resolved === '') {
// [#29069] An empty credential is told apart from one that is gone, in
// the connect path's wording (`DatasourceConnectionService.connect`'s
// `secret === ''` branch): a row sealed empty before the admin doors
// refused an empty `secret` decrypts to ''. The outcome is the same
// refusal — dropping the inline copy would leave nothing that works.
if (resolved === '') {
return this.refuse(
name,
`Datasource '${name}' already references a stored secret, but credential `
+ `'${plan.credentialsRef}' is empty (its sys_secret row holds an empty value). Removing the `
+ 'inline credential would leave this datasource with no working credential at all.',
'Re-enter the credential in the connection form\'s secret field to set the datasource\'s '
+ 'secret again — that rebinds it and replaces the empty one; the inline copy can then be removed.',
);
}
if (resolved == null) {
return this.refuse(
name,
`Datasource '${name}' already references a stored secret, but it could not be resolved or `
Expand Down
Loading