Skip to content

Commit 501ed0e

Browse files
qq9340100claude
andauthored
fix(settings): verify the repoint before reaping a rotated secret (#8262) (#8680)
* fix(settings): verify the repoint before reaping a rotated secret (#8262) `reapRotatedSecret` deleted the handle `upsertRow` reported as `previousEnc` and inferred that the repoint had taken effect from `previousEnc !== nextEnc`. That inference fails on a `SettingsEngine` adapter that drops `context`: the readonly `value_enc` is stripped from the non-system UPDATE, the row keeps naming the old handle, and the reaper destroyed the ciphertext STILL IN FORCE — leaving a dangling `value_enc` that reads as empty, unrecoverably. Re-read the row and delete only once storage confirms it no longer names the handle. Refusals leave an orphan (recoverable) and are logged loudly, so a non-forwarding adapter now announces itself instead of silently losing values. The verification read sits behind every cheap guard and inside the reaper's existing "never fail the write" guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH * refactor(settings): keep the verification read on the declared options type (#8262) `rowIdentity`'s `bypass` is typed `{ bypassTenantAudit?: true }` rather than `Record<string, unknown>`, so the reaper's re-read spreads into a `SettingsEngine.find` options object without an `as any`. The query-options-erasure ratchet caught the erasure as a new site — correctly: the verification read's whole value is that it goes through the declared contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9f5950 commit 501ed0e

5 files changed

Lines changed: 450 additions & 41 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/service-settings": patch
3+
---
4+
5+
fix(settings): the rotated-secret reaper verifies the repoint instead of inferring it (#8262)
6+
7+
`SettingsService.reapRotatedSecret` deleted the `sys_secret` row that
8+
`upsertRow` reported as `previousEnc`, and inferred that the repoint it was
9+
cleaning up after had taken effect from `previousEnc !== nextEnc`. That
10+
inference holds for the shipped adapter, which forwards
11+
`context: { isSystem: true }`. It does not hold for an adapter that drops
12+
`context` — the reader `SettingsEngine`'s own doc comment contemplates, and a
13+
documented extension point rather than a mistake nobody makes.
14+
15+
With `context` dropped, `sys_setting.value_enc` is `readonly: true` so the
16+
UPDATE has it stripped, the row keeps naming the OLD handle, and the reaper
17+
then deleted **the ciphertext still in force**: `materialiseRow` dereferenced a
18+
dangling handle, got nothing, and the setting silently read as empty. That is
19+
unrecoverable — the audit trail records digests, never handles or ciphertext,
20+
so nothing can even name what was destroyed. Measured on the real engine over
21+
the real `SysSetting` / `SysSecret` schemas, three writes gave `sys_secret`
22+
`1 → 1 → 2` with `value_enc` pinned to a row that no longer existed.
23+
24+
The reaper now re-reads the row after the write and deletes `previousEnc` only
25+
once storage confirms the row no longer names it. The criterion is
26+
`current !== previousEnc` rather than the narrower `current === nextEnc`:
27+
under a concurrent rotation the row may already have moved on to a third
28+
handle, where `previousEnc` is genuinely unreferenced and the narrower test
29+
would leak the orphan the reaping exists to prevent. Both refuse the case that
30+
matters.
31+
32+
Every refusal branch (unreadable row, failed read, row still naming the
33+
handle) leaves an orphan and logs — the recoverable direction, and the one an
34+
orphan sweep can clean up; there is no recoverable direction on the other
35+
side. The added read sits behind every cheap guard, so it is paid only where a
36+
destructive delete would otherwise follow, and it is inside the same
37+
best-effort guarantee as the delete: a rotation is never failed by it.
38+
39+
Latent rather than live: no shipped path reaches this, because the shipped
40+
adapter forwards `context`. The population at risk is third-party and custom
41+
`SettingsEngine` adapter authors — who also had no discovery path, since the
42+
warning on `SettingsEngine.update` still described only the pre-#8063
43+
consequence ("the rotated-away credential stays in force"). That warning now
44+
states the real consequence, and a non-forwarding adapter announces itself in
45+
the log instead of failing silently.

packages/services/service-settings/src/settings-secret-rotation.test.ts

Lines changed: 206 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,19 @@
6666
*/
6767

6868
import { describe, expect, it, vi } from 'vitest';
69-
import { ObjectQL } from '@objectstack/objectql';
69+
// `assertEngineUpdateDispatch` re-exports the shared producer-side predicate
70+
// (`@objectstack/metadata-core` since #5619) through objectql, which is already
71+
// a devDependency here and already aliased to SOURCE by `vitest.config.ts` — so
72+
// the #8262 doubles below stay pinned to the real dispatch contract without
73+
// adding a dependency or an alias.
74+
import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql';
7075
import { SysSecret, SysSetting } from '@objectstack/platform-objects/system';
7176
import type { SettingsManifest } from '@objectstack/spec/system';
7277
import { SettingsService } from './settings-service.js';
7378
import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js';
7479
import { LocalCryptoProvider } from './local-crypto-provider.js';
7580
import { dropEchoedSecretMasks, SETTINGS_SECRET_MASK } from './settings-secret-redaction.js';
76-
import type { SettingsSecretStore } from './settings-service.types.js';
81+
import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js';
7782

7883
// ---------------------------------------------------------------------------
7984
// Fixtures
@@ -173,17 +178,87 @@ function makeMemoryDriver() {
173178
return { driver, rowsOf };
174179
}
175180

181+
/**
182+
* [#8262] Reproduces the adapter the `SettingsEngine` doc comment warns about:
183+
* identical to `wrapEngineAsSettingsEngine` except that it DROPS `context` on
184+
* the way to the engine.
185+
*
186+
* That single omission is the whole hazard — `sys_setting.value_enc` is
187+
* declared `readonly: true` and the engine strips author-declared read-only
188+
* columns from a NON-system caller's UPDATE (`stripReadonlyFields`, gated on
189+
* `context.isSystem`), while the INSERT path is exempt (#3413). It is a
190+
* documented extension point, so the population that reaches it is real:
191+
* third-party adapter authors, who have no other discovery path.
192+
*/
193+
function wrapEngineDroppingContext(engine: any): SettingsEngine {
194+
const real = wrapEngineAsSettingsEngine(engine);
195+
return {
196+
find: real.find.bind(real),
197+
insert: real.insert.bind(real),
198+
async update(objectName, opts) {
199+
// Loose in exactly ONE dimension — `context` — and conformant in every
200+
// other, or the row states it produces stop being evidence about the
201+
// real engine. `multi: true` is passed unconditionally because that IS
202+
// the settings adapter's contract (a scalar `where.id` outranks `multi`
203+
// in the shared predicate, and the settings row write never has one).
204+
assertEngineUpdateDispatch((opts as any)?.data ?? {}, {
205+
where: (opts as any)?.where,
206+
multi: true,
207+
});
208+
const { context: _dropped, ...withoutContext } = opts as any;
209+
return real.update(objectName, withoutContext);
210+
},
211+
};
212+
}
213+
214+
/**
215+
* [#8262] Forwards `context` correctly, but makes the FIRST read after any
216+
* update throw — which is exactly the reaper's post-write verification read.
217+
*
218+
* The constraint this exists to pin: `reapRotatedSecret` runs after the write
219+
* has committed and is "never allowed to fail the write" (its call site says
220+
* so). Adding a read to it must not turn a transient read failure into a
221+
* failed rotation.
222+
*/
223+
function wrapEngineFailingPostUpdateReads(engine: any): SettingsEngine {
224+
const real = wrapEngineAsSettingsEngine(engine);
225+
let armed = false;
226+
return {
227+
async find(objectName, opts) {
228+
if (armed) {
229+
armed = false;
230+
throw new Error('read replica offline');
231+
}
232+
return real.find(objectName, opts);
233+
},
234+
insert: real.insert.bind(real),
235+
async update(objectName, opts) {
236+
assertEngineUpdateDispatch((opts as any)?.data ?? {}, {
237+
where: (opts as any)?.where,
238+
multi: true,
239+
});
240+
const res = await real.update(objectName, opts);
241+
armed = true;
242+
return res;
243+
},
244+
};
245+
}
246+
176247
/**
177248
* The four pieces the running server bolts together: a real engine over the
178249
* real system objects, the real `IDataEngine → SettingsEngine` adapter, the
179250
* real `sys_secret` store the plugin builds, and the real service.
180251
*
181252
* `secretStoreOverrides` lets one case break `delete` without touching the
182-
* others; `withDelete: false` reproduces a store that cannot reap at all.
253+
* others; `withDelete: false` reproduces a store that cannot reap at all;
254+
* `forwardContext: false` / `failVerificationRead` swap in the two #8262
255+
* adapters above.
183256
*/
184257
async function boot(opts: {
185258
secretStoreOverrides?: Partial<SettingsSecretStore>;
186259
withDelete?: boolean;
260+
forwardContext?: boolean;
261+
failVerificationRead?: boolean;
187262
} = {}) {
188263
const engine = new ObjectQL();
189264
const { driver, rowsOf } = makeMemoryDriver();
@@ -218,7 +293,11 @@ async function boot(opts: {
218293
const logged: string[] = [];
219294
const svc = new SettingsService({
220295
env: {},
221-
engine: wrapEngineAsSettingsEngine(engine as any),
296+
engine: opts.failVerificationRead
297+
? wrapEngineFailingPostUpdateReads(engine)
298+
: opts.forwardContext === false
299+
? wrapEngineDroppingContext(engine)
300+
: wrapEngineAsSettingsEngine(engine as any),
222301
cryptoProvider: new LocalCryptoProvider(),
223302
secretStore: { ...baseStore, ...(opts.secretStoreOverrides ?? {}) },
224303
logger: { error: (m) => { logged.push(m); } },
@@ -460,3 +539,126 @@ describe('#8030 — wrapEngineAsSettingsEngine forwards the execution context',
460539
expect(calls[0][2]).toMatchObject({ bypassTenantAudit: true, context: { isSystem: true } });
461540
});
462541
});
542+
543+
// ---------------------------------------------------------------------------
544+
// 8. #8262 — the reaper VERIFIES the repoint instead of inferring it
545+
// ---------------------------------------------------------------------------
546+
547+
/**
548+
* #8262 — `reapRotatedSecret` deleted the handle `upsertRow` reported as
549+
* `previousEnc` without ever confirming the repoint it was cleaning up after
550+
* had taken effect; it inferred that from `previousEnc !== nextEnc`.
551+
*
552+
* That inference holds for the shipped adapter (which forwards
553+
* `context: { isSystem: true }`) and fails for one that drops it — the reader
554+
* `SettingsEngine`'s own doc comment contemplates. With `context` dropped the
555+
* UPDATE has `value_enc` stripped, so the row still names `previousEnc`, and
556+
* the reaper deleted **the ciphertext still in force**: `materialiseRow`
557+
* dereferences a dangling handle, gets nothing, and the setting silently reads
558+
* empty. Unrecoverable — the audit trail records digests, never handles.
559+
*
560+
* Before the reaper existed the same adapter bug was non-destructive (the
561+
* rotated-away credential merely stayed in force). These cases pin that the
562+
* failure mode is back to recoverable, and now LOUD rather than silent.
563+
*
564+
* ⚠️ Direction of the counterfactual: on the pre-fix source, `three writes`
565+
* below reads `[1, 1, 2]` and the value reads back `null`. The fix does not
566+
* make a context-dropping adapter correct — nothing at this layer can, the
567+
* repoint is stripped one layer down — it makes the failure survivable.
568+
* Sections 5 and 6 are the other half of the pin: with `context` forwarded,
569+
* the verification passes and reaping still happens on every rotation, so a
570+
* fix that simply stopped reaping would go red there.
571+
*/
572+
describe('#8262 — the reaper never deletes the ciphertext that is still in force', () => {
573+
it('a context-dropping adapter keeps the in-force ciphertext, and the setting still reads', async () => {
574+
const { svc, settingRow, secretRows } = await boot({ forwardContext: false });
575+
576+
await svc.set('sms', 'twilio_auth_token', 'alpha');
577+
const handleA = settingRow()?.value_enc as string;
578+
expect(handleA).toMatch(/^sec_/);
579+
expect(secretRows()).toHaveLength(1);
580+
581+
// The second write is where the defect lived: the repoint is stripped, so
582+
// `value_enc` still names `handleA`, while `upsertRow` reports it as the
583+
// handle rotated AWAY from.
584+
await svc.set('sms', 'twilio_auth_token', 'beta');
585+
586+
// The strip itself is NOT this card's subject and is unchanged: the row
587+
// still names the old handle. What must never happen is the deletion.
588+
expect(settingRow()?.value_enc).toBe(handleA);
589+
590+
// ⛔ THE assertion. Pre-fix this row was gone and `value_enc` dangled.
591+
expect(secretRows().some((r) => r.id === handleA)).toBe(true);
592+
593+
// …and the consequence that makes it data loss rather than a stale value:
594+
// pre-fix this read returned `null` with the credential unrecoverable.
595+
expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('alpha');
596+
});
597+
598+
it('three writes leave the recoverable pre-reaper shape (1→2→3), not the destructive one (1→1→2)', async () => {
599+
const { svc, settingRow, secretRows } = await boot({ forwardContext: false });
600+
601+
await svc.set('sms', 'twilio_auth_token', 'tok-1');
602+
const pinned = settingRow()?.value_enc as string;
603+
const afterFirst = secretRows().length;
604+
605+
await svc.set('sms', 'twilio_auth_token', 'tok-2');
606+
const afterSecond = secretRows().length;
607+
608+
await svc.set('sms', 'twilio_auth_token', 'tok-3');
609+
const afterThird = secretRows().length;
610+
611+
// The card's table, inverted. `[1, 1, 2]` is the destructive shape: the
612+
// second write deleted the row the setting pointed at.
613+
expect([afterFirst, afterSecond, afterThird]).toEqual([1, 2, 3]);
614+
expect(settingRow()?.value_enc).toBe(pinned);
615+
expect(secretRows().some((r) => r.id === pinned)).toBe(true);
616+
expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('tok-1');
617+
});
618+
619+
it('says so LOUDLY — the failure the adapter doc calls silent now names itself', async () => {
620+
const { svc, logged } = await boot({ forwardContext: false });
621+
await svc.set('sms', 'twilio_auth_token', 'alpha');
622+
await svc.set('sms', 'twilio_auth_token', 'beta');
623+
624+
const out = logged.join('\n');
625+
// The operator's question is "did my rotation happen?", so the message has
626+
// to answer that, name the row, and name the cause.
627+
expect(out).toMatch(/did NOT take effect/);
628+
expect(out).toMatch(/sms\.twilio_auth_token/);
629+
expect(out).toMatch(/context/);
630+
// It must be a refusal, not a report of something already destroyed.
631+
expect(out).toMatch(/REFUSED to delete/);
632+
});
633+
634+
it('a verification read that THROWS still leaves the rotation landed (never fails the write)', async () => {
635+
const { svc, settingRow, secretRows, logged } = await boot({ failVerificationRead: true });
636+
637+
await svc.set('sms', 'twilio_auth_token', 'alpha');
638+
const handleA = settingRow()?.value_enc as string;
639+
640+
// The write itself must survive a reaper that cannot verify — the call
641+
// site's "never allowed to fail the write" constraint covers the read the
642+
// fix added, not just the delete.
643+
await expect(svc.set('sms', 'twilio_auth_token', 'beta')).resolves.toBeDefined();
644+
645+
// The rotation landed (context IS forwarded here) …
646+
const handleB = settingRow()?.value_enc as string;
647+
expect(handleB).not.toBe(handleA);
648+
expect((await svc.get<string>('sms', 'twilio_auth_token')).value).toBe('beta');
649+
650+
// … and the unverifiable handle was left alone rather than destroyed on a
651+
// guess. An orphan is recoverable; a deleted in-force ciphertext is not.
652+
expect(secretRows().some((r) => r.id === handleA)).toBe(true);
653+
expect(logged.join('\n')).toMatch(/could not confirm/);
654+
});
655+
656+
it('a store with no delete is unaffected — no verification read is issued at all', async () => {
657+
// The verification read costs one I/O and must only be paid where a
658+
// destructive delete would otherwise follow.
659+
const { svc, secretRows } = await boot({ withDelete: false, forwardContext: false });
660+
await svc.set('sms', 'twilio_auth_token', 'alpha');
661+
await svc.set('sms', 'twilio_auth_token', 'beta');
662+
expect(secretRows()).toHaveLength(2);
663+
});
664+
});

packages/services/service-settings/src/settings-service-plugin.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -318,10 +318,12 @@ export class SettingsServicePlugin implements Plugin {
318318
);
319319
},
320320
async delete(id) {
321-
// [#8030] The rotated-away ciphertext. `sys_setting.value_enc` has
322-
// already been repointed by the time this runs, so the row is
323-
// unreferenced — see `SettingsService.reapRotatedSecret` for why
324-
// leaving it is a security problem rather than untidiness.
321+
// [#8030] The rotated-away ciphertext. By the time this runs the
322+
// caller has re-read `sys_setting.value_enc` and CONFIRMED it no
323+
// longer names this handle ([#8262] — it used to infer that), so the
324+
// row is genuinely unreferenced — see `SettingsService.reapRotatedSecret`
325+
// for why leaving it is a security problem rather than untidiness, and
326+
// why the confirmation is not optional.
325327
//
326328
// System-elevated for the same reason the settings row update is:
327329
// `sys_secret` is a platform-owned table and this is the platform

0 commit comments

Comments
 (0)