Skip to content

fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) - #8063

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-8030-settings-rotation-repoint-handle
Aug 12, 2026
Merged

fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030)#8063
huangyiirene merged 1 commit into
mainfrom
claude/issue-8030-settings-rotation-repoint-handle

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#8030

The defect

A secondPUT of a new value for an encrypted setting key answered 200 with a correctly redacted body, advanced updated_at, wrote an audit row and inserted a genuinely new sys_secret row holding the new plaintext — and left sys_setting.value_enc pointing at the first handle. The effective secret never changed.

Every signal an administrator can see said the rotation landed. Rotating a leaked SMTP password or provider API key looked exactly like a rotation that worked, while the leaked credential stayed the one in force. The first write of any secret was correct, so it was invisible until the second.

Root cause — the filer's analysis held, verified end-to-end

sys_setting.value_enc (and updated_by) are declared readonly: true in packages/platform-objects/src/system/sys-setting.object.ts, and the engine strips author-declared read-only columns from a non-system caller's UPDATE payload (stripReadonlyFields, gated on if (!opCtx.context?.isSystem) in packages/objectql/src/engine.ts). SettingsService.upsertRow persisted through a plain, un-elevated engine.update, so the handle could never be repointed. The INSERT path is deliberately exempt from that strip (#3413) — exactly why the first write landed and every later one did not.

The engine's own warning names the fix:

Field 'value_enc' on 'sys_setting' is read-only: the caller-supplied value was DROPPED and the update is being COMMITTED WITHOUT IT — the call returns success while this column keeps its stored value (#2948). Server-side code that legitimately writes read-only columns … must declare itself trusted by passing { context: { isSystem: true } } on the write.

The fix — service side only, no cross-lane change

  • upsertRow performs its UPDATE as a system write.SettingsService is a privileged writer: the manifest capability gate (assertPermitted), the env-lock / upper-scope-lock pre-flight and validatePatch have all run by then, and these are columns it owns rather than ones a caller forged. Same posture and same measured reason as ObjectQL.recomputeSummaries' elevation (crud-permission-matrix: an allowed member create of showcase_task returns 500 while the row is written (summary recompute runs under caller context) #7673).
  • SettingsEngine.update gains a context member, and the IDataEngine adapter forwards it on both branches. The settings row write takes the multi one (its where is the composite (namespace, key, scope, user_id), never an id), so the by-id branch is the half that would rot unnoticed — it is pinned.
  • ⚠️value_enc stays readonly: true. The elevation is scoped to this one write, so an external caller reaching sys_setting through the data layer still cannot repoint a secret handle. That flag is a security control; removing it would have been the wrong direction on this defect. There is a test that fails if someone removes it.

Nothing in packages/platform-objects or packages/objectql is touched — the STOP fork on the card did not fire.

Orphan sys_secret rows — reaped, not accepted

The row a rotated-away handle named is deleted once the repoint has committed. An orphan is a decryptable copy of exactly the credential the admin just retired, accumulating one per rotation (the filer measured 7 → 8 → 9 across three writes) — so the exposure grows with the hygiene we ask operators to practise. Nothing else can reference the handle: ids are minted per encrypt() call, value_enc is the only column that holds one, and the audit trail records digests rather than handles, so it stays readable after the ciphertext is gone.

The store's delete is optional (pre-existing fakes and the legacy inline-crypto path simply have none) and the call is best-effort: the new secret is already in force by then, so a failed cleanup is reported loudly, never raised.

Verification

Tests boot a real ObjectQL over the real SysSetting / SysSecret schemas and drive the real SettingsService through the real adapter — the four pieces the running server bolts together — so the verdict is about the engine's actual strip rule rather than about a fake written to match the fix.

Reverse-verification, with only the fix's two behavioural lines removed and everything else identical:

rotation test
fix removed6 failed / 6 passedexpected 'sec_d261eae…' not to be 'sec_d261eae…' at the second write, sys_secret 1 → 2
fix applied12 passed

The 6 that stayed green with the fix removed are the ones that should: the first-write path, the readonly-for-external-callers pin, and the mask-echo no-op.

Not regressed


Generated by Claude Code

…tion, and reap the retired ciphertext (#8030)
A second PUT of a new value for an encrypted setting key answered 200 with a
correctly redacted body, advanced `updated_at`, wrote an audit row and inserted
a genuinely new `sys_secret` row holding the new plaintext — and left
`sys_setting.value_enc` pointing at the FIRST handle. The effective secret never
changed, so a leaked credential survived the rotation meant to retire it, with
every visible signal saying the rotation had landed. The first write of any
secret was correct, so it was invisible until the second.
Cause: `sys_setting.value_enc` (and `updated_by`) are declared `readonly: true`,
and the engine strips author-declared read-only columns from a NON-system
caller's UPDATE payload (`stripReadonlyFields`, gated on
`!opCtx.context?.isSystem`). `SettingsService.upsertRow` persisted through a
plain, un-elevated `engine.update`, so the handle could never be repointed. The
INSERT path is deliberately exempt from that strip (#3413) — which is exactly
why the first write landed and every later one did not.
Fix, on the service side only:
- `upsertRow` performs its UPDATE as a system write. `SettingsService` is a
privileged writer — the manifest capability gate, the env/upper-scope lock
pre-flight and `validatePatch` have all run by then, and these are columns it
owns rather than ones a caller forged. Same posture, same reason, as
`ObjectQL.recomputeSummaries`' elevation (#7673).
- `SettingsEngine.update` gains a `context` member and the `IDataEngine`
adapter forwards it on BOTH branches. The settings row write takes the
`multi` one (its `where` is the composite key, never an `id`), so the by-id
branch is the half that would rot unnoticed.
- `value_enc` STAYS `readonly: true`. The elevation is scoped to this one
write, so an external caller reaching `sys_setting` through the data layer
still cannot repoint a secret handle. A test fails if that flag is removed.
Orphans are reaped rather than accepted: the `sys_secret` row a rotated-away
handle named is deleted once the repoint has committed. An orphan is a
decryptable copy of the credential the admin just retired, one more per
rotation. The store's `delete` is optional and the call is best-effort — the new
secret is already in force by then, and a failed cleanup is reported, never
raised.
Tests boot a real ObjectQL over the real `SysSetting` / `SysSecret` schemas and
drive the real service through the real adapter, so the verdict is about the
engine's actual strip rule rather than about a fake written to match the fix.
`vitest.config.ts` aliases `@objectstack/objectql` and `@objectstack/core` to
source, per `check-test-source-alias`.
Fixes#8030
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFizXytCQYaGCNvewL7TaE
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:07pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services/service-settings)
  • content/docs/plugins/packages.mdx(via @objectstack/service-settings)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/service-settings)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/service-settings)
  • content/docs/releases/v9.mdx(via @objectstack/service-settings)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 15:32
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 8d01f0eAug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-8030-settings-rotation-repoint-handle branch August 12, 2026 16:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@huangyiirene@claude