Repository navigation
Commit 179598e
fix(service-settings): a secret overwrite's audit row records the previous value's keyed digest as old_hash (#26600)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFUNxcpaxEv3ncMRqqPo9pj)_
Fixes #26534
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么**:密钥类设置(`encrypted: true`)被覆盖时,审计行 `old_hash` 不再恒为
null,而是记上一次值的带密钥摘要(`hmac-sha256:…`),与 `new_hash` 同一算法;所以覆盖时 `old_hash`
等于上一行的 `new_hash`,同值重写时 `old_hash == new_hash`。
- **为什么改**:列声明“仅在之前未设置时为 null”,但密钥行一直写
null,运维无法从审计判断一次轮换是真换了密钥还是写回了同一个值,而这恰是最需要判断的行。
- **风险与回滚**:低。只有“密钥覆盖”这一种写多一次 `sys_secret` 读和一次解密,且在旧密文被回收之前;读不到或解不开时记
null,写入照常成功;明文只在函数内用于算摘要,不记日志、不返回、不存储;从不写无密钥哈希。回滚即 revert 本 PR。
-
**先看**:`packages/services/service-settings/src/settings-service.ts`(`setMany`
中 `upsertRow` 与 `reapRotatedSecret` 之间新增的 `oldHash`,以及
`previousSecretAuditDigest`);`src/settings-audit-old-hash.test.ts`(`#26534`
一组用例)。
- **两分钟验证**:`pnpm --filter @objectstack/service-settings exec vitest run
src/settings-audit-old-hash.test.ts`,预期 14/14 通过,其中 `#26534` 八条。
- **需要你做的**:无,席位落地。
## What changes
`SettingsService.setMany` now computes the audit's `oldHash` after
`upsertRow` (whose existence probe names the previous row) and before
`reapRotatedSecret` (which destroys the previous secret's `sys_secret`
row):
- **Secret write** (`encrypted: true` key): `old_hash` is
`previousSecretAuditDigest(...)`, the same keyed digest `new_hash`
records (`secretAuditDigest`, the provider's `keyedDigest`), taken over
the previous value. The previous plaintext is read through a new private
`decryptStored(row)`, factored out of `materialiseRow` with no behaviour
change there, and is digested and dropped inside that frame. `null` when
the previous row held no secret (no row, a reset row, or a row written
while the key was not secret-valued), when no keyed digest is available
(checked before the read, so that write pays nothing), or when the
previous value cannot be read back (handle gone, decrypt refused; the
latter warns once on the logger with the key and the decrypt error,
never the value). It never fails the write.
- **Non-secret write**: unchanged, `previousAuditDigest(previous)`
(#26465).
- With no `auditWriter` wired, nothing is computed.
Never a digest of ciphertext (it changes on every re-encryption), never
an unkeyed hash of the plaintext. The secret reaper (#8030
`previousEnc`) is unchanged. Changeset: `@objectstack/service-settings`
patch.
## Why
`sys_setting_audit.old_hash` and `sys_platform_setting_audit.old_hash`
declare "Null when previously unset", but the secret arm passed
`oldHash: isEncrypted ? null : ...`, so every secret overwrite recorded
null. The `encrypted` column exists to flag rotation events, and without
`old_hash` an operator cannot tell a rotation that changed the secret
from one that rewrote the same value. Rulings: triage `6436617436` and
the server seat 2 ruling, arm (a).
## Tests
All through `PUT /api/settings/:namespace` on the #26465 harness (real
`ObjectQL`, real platform objects, the plugin's own `sys_secret` store
and audit writer). The old secret CONTROL that pinned `old_hash: null`
is replaced by a `#26534` block:
| Case | Asserted |
|---|---|
| first secret write | `old_hash` null, keyed `new_hash` |
| secret overwrite | `old_hash` keyed and equal to the previous row's
`new_hash`; differs from the new `new_hash` |
| same-value secret rewrite | `old_hash == new_hash == ` previous row's
`new_hash` |
| secret → reset → set | reset: `old_hash` = previous keyed digest,
`new_hash` null; next set: `old_hash` null |
| cost | a non-secret write reads no `sys_secret`; a first write reads
only its own handle; an overwrite reads the previous handle exactly one
more time than a boot with no keyed digest, and that handle is still
reaped afterwards |
| no keyed digest | both columns null, write lands |
| previous handle gone | `old_hash` null, keyed `new_hash`, write lands
and reads back |
| manifest flips the key non-secret → secret → non-secret | both
crossings record `old_hash` null; no unkeyed digest of the secret |
Every secret case keeps the "no plaintext and no unkeyed SHA-256 of the
secret anywhere in the row" assertion.
**Ablation** (committed fix at `d66cfb39`,
`scripts/ablation-replace.mjs`): `await
this.previousSecretAuditDigest(namespace, key, previous)` replaced by
`null`, anchor 1 → 0, blob `148247002eaf` → `0994b6cb2558`. Result: 4
failed, 10 passed (overwrite, same-value, reset, cost). Restored: blob
== HEAD `148247002eaf`, `git diff HEAD` empty.
Package at `d66cfb39`: `pnpm --filter @objectstack/service-settings
test` Test Files 61 passed (61), Tests 1014 passed (1014); `typecheck`
(`tsc --noEmit`, `include: ["src"]`, tests included) exit 0. `eslint
--no-inline-config` on `packages/services/service-settings`: exit 0.
## Acceptance notes
- **Cost measured.** The write path already decrypts the key's current
value once in `validatePatch` (it resolves `get()` for every touched
key), so a secret overwrite now makes two reads of the previous handle
where it made one. That earlier read is the cascade's effective value,
which can come from another rung or the environment, so it is not the
row's previous value and is not reused.
- **Crossing between non-secret and secret is not reachable through
PUT.** `encrypted` comes from the manifest, and only a re-registration
(`registerManifest` replaces the namespace's entry; in practice a deploy
that edits the manifest while stored rows persist) changes it. Both
crossings record `old_hash` null: non-secret → secret has no previous
secret to key-digest, and secret → non-secret must not write an unkeyed
digest of the previous secret. Pinned by the last case.
- **QA checklist.** `platform-core.settings-audit-secret-fingerprint`
says nothing about a secret row's `old_hash`, and no digest algorithm
changed, so its revision is not bumped. Its "no unkeyed digest in any
row" clause still holds: the new `old_hash` is keyed.
## Verification
<details><summary>Derived gate families (`dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, run at d66cfb3)</summary>
60 derived, 59 run with exit 0, 1 NOT MEASURED: `pnpm
check:dual-build-cjs-loads` exit 3 (prerequisite not met: needs a full
build; CI runs it). `node scripts/check-plugin-teardown-shape.mjs
--self-test` first exited 3 on the shallow clone; after `git fetch
origin 621a487` (its pinned fixture) it ran: exit 0, 48 cases.
`dispatch-gates.mjs --ran`: "60 derived famil(ies) accounted for — 59
run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". `test:repo` was
not run locally (the diff touches no repo-project test); CI Test Core
runs it.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched
package.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (Not touched.)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5nYPKQq7oxF4u1xFKB
Co-authored-by: Claude <noreply@anthropic.com>1 parent c8ed4c9 commit 179598e
3 files changed
Lines changed: 295 additions & 47 deletions
File tree
- .changeset
- packages/services/service-settings/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
20 | | - | |
21 | | - | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
22 | 24 | | |
23 | 25 | | |
24 | 26 | | |
| |||
182 | 184 | | |
183 | 185 | | |
184 | 186 | | |
185 | | - | |
| 187 | + | |
186 | 188 | | |
187 | 189 | | |
188 | 190 | | |
189 | 191 | | |
190 | 192 | | |
191 | 193 | | |
192 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
193 | 203 | | |
194 | 204 | | |
195 | 205 | | |
196 | | - | |
197 | | - | |
| 206 | + | |
| 207 | + | |
198 | 208 | | |
199 | 209 | | |
200 | 210 | | |
| |||
238 | 248 | | |
239 | 249 | | |
240 | 250 | | |
241 | | - | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
242 | 264 | | |
243 | 265 | | |
244 | 266 | | |
| |||
330 | 352 | | |
331 | 353 | | |
332 | 354 | | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
333 | 383 | | |
334 | | - | |
| 384 | + | |
335 | 385 | | |
336 | 386 | | |
337 | 387 | | |
| |||
341 | 391 | | |
342 | 392 | | |
343 | 393 | | |
344 | | - | |
345 | 394 | | |
346 | 395 | | |
347 | | - | |
348 | | - | |
349 | | - | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
350 | 524 | | |
351 | 525 | | |
0 commit comments