Skip to content

Commit ecd944f

Browse files
fix(objectql)!: a non-system write to a secret field may only seal a new value or keep its own record's ref (#27695)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27582 Clause-②: no (narrowing) — a non-system write to a secret field refuses a value already spelled `secret:` unless it is the reference that record's column already holds (so a create always refuses one), where it stored such a value as given before. Nothing is widened. Metadata-protocol: none Skills-docs: none — nothing a metadata author writes changes; the rule is inside the engine's credential write path, and the system-context page gains the row CI requires for the new elevation read. ## 维护者速读 - 改了什么:`secret` 字段收到形如 `secret:` 的已存引用时,非系统写入只能保留本记录这一列已存的那个引用(保留即不改写该列);新建记录不接受任何引用;其他引用一律以 `VALIDATION_ERROR` / 400 拒绝,且无论引用指向什么,答复都相同。系统上下文写入保持原样放行。 - 为什么改:分诊裁定"失败即拒绝:非系统写入只能封存新值或保留本记录自己的引用"。此前已是引用的值对所有调用方原样放行。 - 风险与回滚:拒绝发生在任何凭据行或记录写入之前;判断只读本次写入自己的已存行(按 id 更新用已读的前像,按条件更新用已有的匹配行备忘),不查 `sys_secret`。界面回传的掩码、`null` 清空、新值加密、读取掩码都不变。回滚只需 revert 本 PR。 - 先看:`packages/objectql/src/engine-secret-internal-fields.ts`(`encryptSecretFields` 的引用分支与 `SecretRefWriteError`),`packages/objectql/src/engine.ts`(三处调用点各自传入本次写入的已存行),`packages/objectql/src/secret-field-own-ref-write.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/secret-field-own-ref-write.test.ts`,预期 19/19 通过。 - 需要你做的:无,席位落地。 ## What changes The rule, in the secret-fields path #27572 already uses (`encryptSecretFields`, whose body lives in `engine-secret-internal-fields.ts`): **a non-system write may only seal a new value or keep its own record's ref.** - **The ref branch.** A `secret` value that is already a `secret:` ref used to be left as it was for every caller. Now: - The system principal (`context.isSystem === true`, the engine's existing marker) keeps that pass-through, unchanged. - Any other caller's ref is kept only when every row the write lands on already holds exactly that value in that column. Then the key is dropped, as the echoed read mask is, so the statement does not rewrite the column at all. - Anything else is refused with the new `SecretRefWriteError`: ADR-0112 `VALIDATION_ERROR` / 400, the standard-catalog pair the sibling credential refusal (`EmptyCredentialWriteError`) and the connector credential door already use. It is thrown before any credential row or record is written. The message names the column and never the value, and nothing about the handle is looked up, so the answer does not depend on what the ref names. - **Whose rows are "its own".** `encryptSecretFields` takes a `StoredRowsReader`, and each of its three call sites in `engine.ts` says what its own rows are: - the insert passes none (a new record has no ref of its own); - the by-id update passes the pre-image it has already read; - the predicate update passes the matched-row memo (`readPriorRows`). The reader is called only when a non-system payload carries a ref, so every other write reads exactly what it did before. - **Every engine write door.** Single and batch insert, `insertMany` (the refused row is reported on its own and the others land), the by-id update and the predicate update. So every REST door that writes through them is covered too: create, `createMany`, import, PATCH. - **Docs.** `content/docs/permissions/system-context.mdx` gains row 23e for the new elevation read, and its site register is regenerated (`check:system-context-census` requires both). - **Not in this change.** Values stored before this change are not re-examined. The write preview (`ObjectQL.validate()`) runs no credential pass, as before. ## Tests - `packages/objectql/src/secret-field-own-ref-write.test.ts` (new, 19 cases): a real engine over an in-memory driver, on every door listed above. - Refused: a non-system create, batch create, `insertMany` row, or `beforeInsert`-hook value naming another record's ref; the same with no context at all; a by-id update naming another record's ref; a column that holds nothing; a predicate update where one matched row does not hold the ref; a predicate that matches no row. Each refusal is asserted on `code`, `status`, `httpStatus`, `object` and `field`. A ref naming an existing row and one naming nothing get an identical message, which does not contain the handle. - Accepted: keeping the record's own ref, as an extra key or as the only key, and the statement does not carry the column; the echoed mask; a `beforeUpdate` hook that writes the stored row back; a predicate ref that every matched row holds; a new value; and the system principal's pass-through on create, by-id and predicate writes. - `packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts` (new, 5 cases): the same rule at the REST data door of a booted stack with a provider wired. - Refused: `POST /data/:object`, `/createMany` and `/import`, each 400 `VALIDATION_ERROR` or a per-row import error, and nothing written. A PATCH naming another record's ref is refused with the same status, code and message as a PATCH naming a ref that resolves to nothing, and the stored value stays. - Accepted: a PATCH echoing the mask the GET returned (what the form sends back), and a PATCH carrying the record's own ref, leave the stored ref and the credential unchanged. - Unchanged: a new value still seals, and a system-context write still takes a ref as given. - `error-http-status-spelling.test.ts` registers the new refusal (both status spellings agree). ## Acceptance notes - **The `engine.ts` edit is wider than the claim's file surface.** The claim allows `engine.ts` only to pass the system-context flag. That flag was already in reach (`context` is an argument). What was not in reach is the record's own stored row, which the ruling's "its own record's ref" needs, so the three call sites now pass it. Nothing else in `engine.ts` changed. - **What the UI sends.** The form's password widget sends back the value it read, which is the mask. The mask is dropped before the ref branch, so the form round trip is unaffected; the dogfood file pins it. - **Overlap.** #27657 (the same two objectql files, in the delete-release region), #27477 and #27424 (`system-context.mdx`, plus `engine.ts` for #27477) all landed on `main` while this was in progress. `origin/main` 5e2f386 is merged in, and every result below was taken after that merge. None of the 39 open PRs touches any of this PR's seven files, so there is nothing left to `merge-tree` against. Against today's `origin/main` (86f2afe) the merge is still clean, and none of the newer commits touches these files. ## Verification Every reading below is at head `75e18b19`, after `origin/main` 5e2f386 was merged in, unless it names another commit. <details><summary><b>Red first</b>: the tests were committed before the fix (bf7eff9)</summary> - `secret-field-own-ref-write.test.ts`: `Tests 11 failed | 8 passed (19)`. - `secret-field-own-ref-write.dogfood.test.ts`: `Tests 3 failed | 2 passed (5)`. </details> <details><summary><b>Green</b>: build, typecheck, the whole objectql suite, and the credential-channel dogfood files</summary> The first four rows ran under one verify-lock hold; the two eslint rows ran after it. Lock verdict: `command-exit 0`. | Step | Result | |---|---| | `turbo run build --filter='@objectstack/dogfood^...'` | exit 0 | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 | | `pnpm --filter @objectstack/objectql exec vitest run` | `Test Files 491 passed (491)` · `Tests 9224 passed (9224)` | | dogfood, 9 credential-channel files: this PR's file plus `secret-field-delete-release`, `secret-field-orphan-rows`, `write-response-credential-mask`, `flow-credential-channel`, `flow-trigger-record-credential-mask`, `approval-snapshot-credential-field`, `admin-credential-lifecycle` and `webhook-meta-door-credential-redaction` | `Test Files 9 passed (9)` · `Tests 60 passed (60)` | | `eslint --no-inline-config packages/objectql` | exit 0 | | `eslint --no-inline-config` on the new dogfood file | exit 0 | </details> <details><summary><b>Ablations</b>: each clause of the rule goes red when removed, and each restore is proven blob == HEAD</summary> Each leg runs `scripts/ablation-replace.mjs` on one clause, then runs the new unit file (19 cases). The restore is then proven: blob `8796f4077121` (`engine-secret-internal-fields.ts`) and `0af248405c8f` (`engine.ts`) == HEAD, and `git diff HEAD` is empty. | Leg | Mutation | Result | |---|---|---| | A1 | the system-principal check passes every caller | `13 failed \| 6 passed (19)` | | A2 | the "no stored row" clause removed (`--delete`) | `7 failed \| 12 passed (19)` | | A3 | "every matched row holds it" weakened to "some row" | `1 failed \| 18 passed (19)` | | A4 | any stored ref counts, not only the same value | `3 failed \| 16 passed (19)` | | A5 | a kept ref is not dropped from the statement | `2 failed \| 17 passed (19)` | | A6 | the by-id update passes no stored row | `3 failed \| 16 passed (19)` | | A7 | the refusal message carries the value | `2 failed \| 17 passed (19)` | | D (dist) | A1 held in source, `objectql` rebuilt, then the REST dogfood file run | `ablation-dist-preflight` marker present (exit 0) · `3 failed \| 2 passed (5)` · after restore and rebuild, preflight marker absent (exit 0) · `5 passed (5)` | Two first attempts did nothing and were redone; neither counts above. On an earlier head, A2 used an empty replacement where the tool requires `--delete`, and A5 used a replacement that was a substring of its anchor. The tool refused both, so no mutation was applied. The legs above are the corrected forms, all re-measured at `75e18b19`. </details> <details><summary><b>Gates</b>: 94 derived, 94 run, each exit code recorded, 0 NOT-MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 94 commands at `75e18b19`, the same list as before the merge. - Every command ran, and each exit code was captured before any pipe. `--ran` reconcile: `94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero (all 94 carry an exit code and none is 3). All 94 exit 0. - `check:type-check-debt` first answered 3 (prerequisite not met): its closure build timed out on the shared lock queue, so nothing was measured. It was re-run inside this run's own lock hold and exited 0 (`0 raw tsc error(s) total`). - Before the merge, at 2f31768, `check:skill-examples` and `check:dual-build-cjs-loads` first answered 3 (dist missing for packages outside this diff). They were built and both exited 0. After a container restart, `check:dual-build-cjs-loads` answered 1 once, on four unrelated packages whose interrupted builds had left no `.d.ts`. After the full closure build it exited 0 (`112 published require entry point(s) across 69 package(s) load`). - `check:system-context-census` was red until row 23e and its register line were added (`--write`). It exits 0 at this head. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 525ea36 commit ecd944f

7 files changed

Lines changed: 597 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a non-system write to a `secret` field may only seal a new value or keep its own record's reference (#27582)
6+
7+
Clause-②: no (narrowing) — a non-system write to a `secret` field refuses a value already spelled `secret:` unless it is the reference that record's column already holds (so a create always refuses one), where it stored such a value as given before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a write value the engine used to store as given is refused unless it is the record's own reference. The fix for a caller is to send the secret's value, write null, or omit the field, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing in the credential write path (`@objectstack/objectql`), served by every engine write door and every REST data door that writes through one.
13+
14+
- **Before.** A `secret` field value that starts with `secret:` is read as a reference to a credential already stored, not as a credential. Such a value was stored as given, from any caller, on a create or an update.
15+
- **Now.** From any caller but the system principal, such a value is accepted only when it is the reference that record's column already holds. On an update, every record the write lands on must already hold exactly that value, and the column is then left as it is, as an echoed read mask is. A create has no reference of its own, so it never takes one. Anything else is refused with `400 VALIDATION_ERROR`, naming the object and the field and never the value, before any credential row or record is written. The answer is the same whatever the reference names. This holds on the single and batch create, `insertMany` (the refused row is reported on its own and the others land), the by-id update and the predicate update, so on every REST door that writes through them: create, bulk, import and update.
16+
- **Unchanged.** The system principal (`isSystem: true`: seed replay, migration) still stores such a value as given. A new value is sealed as before, `null` clears, an echoed read mask changes nothing, and reads still mask. The Console's password field sends back the mask it read, so the form round trip is unaffected.
17+
- **Who sees it.** A caller without the system context (an API client, an import, a hook or script) that sends a `secret:` value to a `secret` field other than the reference that record already holds.
18+
19+
**The remedy.** To set the secret, send its value; to clear it, write `null`; to keep it, omit the field or echo the read mask.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ bugs — it is the protection or the side effect that silently does not happen.
136136
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `assertPositionNamesCatalogRow` |
137137
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
138138
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` |
139+
| 23e | **A `secret` field takes a stored reference as given** — a value already spelled `secret:` is stored unchanged | objectql | Get: a system writer may copy a `secret:` reference onto a record as it stands (seed replay, a migration). Every other caller may only seal a new value or keep its own record's ref: on a create any such value is refused, and on an update it is accepted only when every record the write lands on already holds exactly that reference in that column, which is then left as it is, as an echoed read mask is. Any other reference is refused `400 VALIDATION_ERROR` before anything is written, with one answer whatever it names. Lose: the check that the reference is the record's own — a system writer that copies one is trusted to mean it. Inbound HTTP never carries the flag | `encryptSecretFields` |
139140
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `buildDriverOptions` |
140141
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed` |
141142
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
@@ -532,6 +533,7 @@ and CI refuses one that differs from what the tree produces.
532533
| `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b |
533534
| `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 |
534535
| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
536+
| `packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields` | 1 | 23e |
535537
| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
536538
| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b |
537539
| `packages/objectql/src/engine.ts#dependentCountIsDisclosable` | 1 | 28 |

‎packages/objectql/src/engine-secret-internal-fields.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
* column off it, has left it unreferenced. [#27581] A committed delete hands
2424
* the same release every handle the deleted rows held (`deletedSecretSlots`).
2525
*
26+
* [#27582] And one on the value already spelled as a ref: a non-system write
27+
* may only seal a new value or keep its own record's ref. `encryptSecretFields`
28+
* takes the write's own stored rows ({@link StoredRowsReader}) and refuses any
29+
* other ref with {@link SecretRefWriteError}; the system principal keeps the
30+
* pass-through.
31+
*
2632
* ## How the moved bodies reach the class
2733
*
2834
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
@@ -106,17 +112,66 @@ export interface SecretHandleSlot {
106112
readonly handleId: string;
107113
}
108114

115+
/**
116+
* [#27582] The rows a write's own record(s) hold in storage now, read for one
117+
* question: is a ref on a non-system payload the value that record's column
118+
* already holds? Each call site says what its own rows are — none for an
119+
* insert (a new record holds no ref of its own), the pre-image for a by-id
120+
* update, the matched rows for a predicate update — and the reader is called
121+
* only when a non-system payload carries a ref, so every other write reads
122+
* nothing more than it did.
123+
*/
124+
export type StoredRowsReader = () => Promise<readonly (Record<string, unknown> | null | undefined)[] | null>;
125+
126+
/**
127+
* [#27582] The ADR-0112 pair carried by the refusal of a ref that is not the
128+
* record's own. `VALIDATION_ERROR` / 400 is the standard-catalog member the
129+
* sibling credential refusals use (`EMPTY_CREDENTIAL_REFUSAL_CODE`): the
130+
* payload is wrong and the caller can fix it, so no ledger registration is
131+
* needed.
132+
*/
133+
export const SECRET_REF_WRITE_REFUSAL_CODE = 'VALIDATION_ERROR';
134+
export const SECRET_REF_WRITE_REFUSAL_STATUS = 400;
135+
136+
/**
137+
* [#27582] Refusal of a `secret:` ref on a non-system write that is not the
138+
* value the record's own column already holds. Fail closed, with one answer:
139+
* the message names the column and never the ref, and nothing about the handle
140+
* the ref names is looked up, so the answer is the same whatever it names.
141+
*/
142+
export class SecretRefWriteError extends Error {
143+
readonly code = SECRET_REF_WRITE_REFUSAL_CODE;
144+
readonly status = SECRET_REF_WRITE_REFUSAL_STATUS;
145+
/** The same number under ADR-0112 D5's spelling, as `EmptyCredentialWriteError` carries it. */
146+
readonly httpStatus = SECRET_REF_WRITE_REFUSAL_STATUS;
147+
readonly object: string;
148+
readonly field: string;
149+
constructor(object: string, field: string) {
150+
super(
151+
`Stored-reference value refused for secret field "${object}.${field}": a value that starts with "secret:" `
152+
+ 'is read as a reference to a credential already stored, not as a credential, and a write may only keep '
153+
+ 'the reference this record already holds. To set the secret, send its value; to clear it, write null; '
154+
+ 'to leave it unchanged, omit the field. Nothing was written.',
155+
);
156+
this.name = 'SecretRefWriteError';
157+
this.object = object;
158+
this.field = field;
159+
}
160+
}
161+
109162
/** The body of `ObjectQL#encryptSecretFields`; the method's docblock there is its contract. */
110163
export async function encryptSecretFields(
111164
this: Pick<SecretInternalFieldsHost, '_registry' | 'cryptoProvider' | 'getDriver'>,
112165
object: string,
113166
row: Record<string, unknown>,
114167
context: ExecutionContext | undefined,
115168
driverOptions: unknown,
169+
storedRows: StoredRowsReader,
116170
): Promise<PendingSecretRow[]> {
117171
const pending: PendingSecretRow[] = [];
118172
if (!row || typeof row !== 'object') return pending;
119173
const schema = this._registry.getObject(object);
174+
let ownRows: readonly (Record<string, unknown> | null | undefined)[] | undefined;
120175

121176
// Echoed-mask drop for every field the read path masks (secret + generic
122177
// password). The read path returns SECRET_MASK; a client that PATCHes it
@@ -141,7 +196,22 @@ export async function encryptSecretFields(
141196
// on the payload, so it must not depend on whether a CryptoProvider or
142197
// sys_secret store happens to be wired.
143198
if (value === '') throw new EmptyCredentialWriteError(object, field, 'secret');
144-
if (isSecretRef(value)) continue; // already encrypted ref
199+
if (isSecretRef(value)) {
200+
// The system principal's ref passes through as it always has: a system
201+
// writer may copy a ref on purpose (seed replay, migration).
202+
if (context?.isSystem === true) continue;
203+
// [#27582] A non-system write may only seal a new value or keep its own
204+
// record's ref: every row this write lands on must already hold exactly
205+
// this value in this column. A write with no own row (an insert, a
206+
// predicate that matches nothing) has nothing to keep. Kept, the key is
207+
// dropped, as the echoed mask is: the column is not rewritten at all, so
208+
// the verdict cannot go stale before the statement runs.
209+
ownRows ??= (await storedRows()) ?? [];
210+
const keepsOwn = ownRows.length > 0 && ownRows.every((own) => own?.[field] === value);
211+
if (!keepsOwn) throw new SecretRefWriteError(object, field);
212+
delete row[field];
213+
continue;
214+
}
145215

146216
if (!this.cryptoProvider) {
147217
throw new Error(

‎packages/objectql/src/engine.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
stripSearchCompanionFromRead,
126126
type PendingSecretRow,
127127
type SecretHandleSlot,
128+
type StoredRowsReader,
128129
} from './engine-secret-internal-fields.js';
129130
// [#25522 cut 6] The predicate-write hook dispatch bodies; the methods of the
130131
// same names below hand each body the members it reads (`HookDispatchHost`).
@@ -10575,7 +10576,16 @@ export class ObjectQL implements IObjectQLEngine {
1057510576
* (maintainer ruling 2026-08-13 on #8559). The `password` half of the same
1057610577
* ruling lands at its own seam — {@link refuseEmptyPasswordFields} — since
1057710578
* the two types share the read mask but not this encryption path.
10578-
* - Secret value already a ref (re-save of an unchanged ref) ⇒ left as-is.
10579+
* - Secret value already a ref ⇒ [#27582] a non-system write may only seal
10580+
* a new value or keep its own record's ref. From the system principal
10581+
* (`context.isSystem === true`) it is left as-is, as it always was. From
10582+
* any other caller it is kept only when every row the write lands on
10583+
* already holds exactly that ref in that column — read from `storedRows`,
10584+
* which each call site supplies (none for an insert, the pre-image for a
10585+
* by-id update, the matched rows for a predicate update) — and then the
10586+
* key is dropped, as the echoed mask is. Any other ref is REFUSED with
10587+
* {@link SecretRefWriteError} (ADR-0112 `VALIDATION_ERROR`/400), one
10588+
* answer whatever the ref names.
1057910589
* - **Fail-closed:** any other secret value with no CryptoProvider registered,
1058010590
* or no reachable `sys_secret` store, THROWS — never persists cleartext.
1058110591
* (A `password` field needs no CryptoProvider — it is stored as-is.)
@@ -10585,6 +10595,7 @@ export class ObjectQL implements IObjectQLEngine {
1058510595
row: Record<string, unknown>,
1058610596
context: ExecutionContext | undefined,
1058710597
driverOptions: unknown,
10598+
storedRows: StoredRowsReader,
1058810599
): Promise<PendingSecretRow[]> {
1058910600
// [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`).
1059010601
const readRegistry = () => this._registry;
@@ -10594,7 +10605,7 @@ export class ObjectQL implements IObjectQLEngine {
1059410605
get cryptoProvider() { return readCryptoProvider(); },
1059510606
getDriver: (...a) => this.getDriver(...a),
1059610607
};
10597-
return encryptSecretFields.call(host, object, row, context, driverOptions);
10608+
return encryptSecretFields.call(host, object, row, context, driverOptions, storedRows);
1059810609
}
1059910610

1060010611
/**
@@ -15374,7 +15385,8 @@ export class ObjectQL implements IObjectQLEngine {
1537415385
// carries the secret-arm refusal). Same try, so partial mode
1537515386
// reports either refusal per-row instead of killing the batch.
1537615387
this.refuseEmptyPasswordFields(object, rows[i]);
15377-
sealedSecrets[i] = await this.encryptSecretFields(object, rows[i], opCtx.context, driverOptions);
15388+
// [#27582] A new record holds no ref of its own: no stored rows.
15389+
sealedSecrets[i] = await this.encryptSecretFields(object, rows[i], opCtx.context, driverOptions, async () => []);
1537815390
} catch (e) {
1537915391
if (!partialMode) throw e;
1538015392
rowErrors[i] = e;
@@ -17029,7 +17041,8 @@ export class ObjectQL implements IObjectQLEngine {
1702917041
// [#8559] Password half of the credential write door, then the
1703017042
// secret channel (which carries the secret-arm refusal).
1703117043
this.refuseEmptyPasswordFields(object, hookContext.input.data as Record<string, unknown>);
17032-
sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
17044+
// [#27582] This record's own stored row is the pre-image read above.
17045+
sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options, async () => [priorRecord]);
1703317046
// [#21663] Scope `'skip'` — the public `validateRecord` IS that
1703417047
// scope: the readonly strip has NOT run yet, so a readonly value
1703517048
// here may be a caller's the strip is about to drop — judged, a
@@ -17426,7 +17439,9 @@ export class ObjectQL implements IObjectQLEngine {
1742617439
// [#8559] Password half of the credential write door, then the
1742717440
// secret channel (which carries the secret-arm refusal).
1742817441
this.refuseEmptyPasswordFields(object, hookContext.input.data as Record<string, unknown>);
17429-
sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
17442+
// [#27582] The rows this write lands on are the matched rows: the
17443+
// D7 memo, read here only when a non-system payload carries a ref.
17444+
sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options, readPriorRows);
1743017445
// [#21663] Scope `'skip'` — the public `validateRecord` IS that
1743117446
// scope: the readonly strip has NOT run yet, so a readonly value
1743217447
// here may be a caller's the strip is about to drop — judged, a

‎packages/objectql/src/error-http-status-spelling.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { DuplicateRecordError } from './duplicate-record-error.js';
3737
import { HookUnscopedDataAccessError } from './hook-run-as.js';
3838
import { MultiUpdateHookKeyDivergenceError } from './multi-update-hook-key-divergence.js';
3939
import { EmptyCredentialWriteError } from './secret-fields.js';
40+
import { SecretRefWriteError } from './engine-secret-internal-fields.js';
4041
import { SystemWriteOrganizationRequiredError } from './tenancy/system-write-organization.js';
4142
import { NamespaceConflictError, ArtifactObjectNameConflictError, ObjectOwnershipConflictError } from './registry.js';
4243
import { invalidFilterError } from './filter-comparand-shape.js';
@@ -52,6 +53,7 @@ const CONSTRUCTED: Array<[string, () => unknown, number]> = [
5253
['HookUnscopedDataAccessError', () => new HookUnscopedDataAccessError({ hook: 'beforeFind', object: 'customer', event: 'beforeFind' }), 403],
5354
['MultiUpdateHookKeyDivergenceError', () => new MultiUpdateHookKeyDivergenceError('customer', ['a', 'b'], 2), 400],
5455
['EmptyCredentialWriteError', () => new EmptyCredentialWriteError('datasource', 'secret_key', 'secret'), 400],
56+
['SecretRefWriteError', () => new SecretRefWriteError('datasource', 'secret_key'), 400],
5557
['SystemWriteOrganizationRequiredError', () => new SystemWriteOrganizationRequiredError('sys_job', 'group', 'walled-posture'), 500],
5658
['NamespaceConflictError', () => new NamespaceConflictError('crm', 'pkg_a', 'pkg_b'), 422],
5759
['ArtifactObjectNameConflictError', () => new ArtifactObjectNameConflictError('customer', 'pkg_a', 'pkg_b'), 422],

0 commit comments

Comments
 (0)