Repository navigation
Commit cad764d
fix(objectql)!: an update compares a value the caller sends only against the stored row as the caller reads it (#31063)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: yes (narrowing) — a non-system update that sends a `secret:`
reference is refused even when the record already holds it, and a
non-system update naming the master-detail FK of a detail with a
parent-scoped `requiredWhen`, in a FK column it may edit but not read,
no longer lets a row that already lacks a required value rest under the
header it names.
Metadata-protocol: none
## 维护者速读
- 改了什么:update 时把调用方送来的值与存储行比较的两处(secret 引用保留、master-detail
改挂时的前置检查读取旧表头),都改成只按调用方自己的读门所见来比较;系统写不变。
- 为什么改:对一个调用方能写不能读的列,update 的答复不应随该列的存储值而变;此前这两处直接比较未投影的存储行。
- 风险与回滚:收窄。非系统写送回 `secret:` 引用一律 400(含记录自己的引用);FK
不可读者点名表头时,缺必填值的遗留行不再“留存”。回滚即还原两处 engine 代码。
- 先看:`packages/objectql/src/engine.ts` 按 id 与批量两处
`priorMasterIdsCallerReads`
调用;`packages/objectql/src/engine-secret-internal-fields.ts`
的保留分支;`packages/objectql/src/engine-master-detail-related.ts` 新增的
`priorMasterIdsCallerReads` 与预览 `previousParents` 的同步。
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
src/reference-forgiveness-readable-column.test.ts`,预期 44 个全绿。
- 需要你做的:无,席位落地。
## What changes
An update compares a value the caller sends with the stored row in two
more places than the reference check, and both now read the stored row
only as the caller's own read door serves it
(`storedReferencesCallerReads`, the one projection the reference check
already uses), by id and in bulk. A system write is unchanged at both.
- **`secret` reference keep** (`encryptSecretFields`). A non-system
write keeps a `secret:` reference only where every row it lands on
serves exactly that reference to the caller's read door. The read path
serves a `secret` column masked to every caller, so no reference is
kept: every non-system reference is refused `400 VALIDATION_ERROR`, one
answer whatever it names and whatever the column holds. Before, the
record's own stored reference was accepted and kept. The echoed mask, a
new value and `null` behave as before; the system principal keeps the
pass-through. The refusal sentence now says a reference is kept only
where the caller reads it in the column, and names omitting the field or
sending back the mask as the way to leave it unchanged.
- **Master-detail repoint pre-check** (`update`, by id and bulk). On a
detail with a parent-scoped `requiredWhen`, when the write names the FK,
the header the ADR-0113 pre-check reads as `previousParent` comes from
the stored FK only as the caller's read door serves it
(`priorMasterIdsCallerReads`). A FK the caller cannot read as stored
leaves the prior header unknown, bound as the unresolved header, so
whether a second header is read, and which, does not depend on the
stored value. A FK the caller reads is judged as before, and a write
that does not name the FK reads nothing more.
- **The validate preview** (`resolvePreviewParents`, reached only from
`validate()`, whose stored rows are already projected through
`servedAsStored`) now binds the prior header the way the write does:
when the patch names the FK over a stored FK the caller was not served
as stored, `previousParents[i]` is the unresolved header (`null`).
Preview and write now give one verdict for that caller, whatever header
the row hung off.
- `content/docs/permissions/system-context.mdx` row 23e states the new
answer.
## Tests
-
`packages/plugins/plugin-security/src/reference-forgiveness-readable-column.test.ts`,
two new top-level describes on the real security plugin:
- pre-check: with the FK `{ readable: false, editable: true }`, naming a
header answers identically whether the line hangs off that header or
another, by id and in bulk, and nothing is written; controls: an edit
that does not name the FK lands, a caller who reads the FK keeps the
legacy rest and the repoint refusal, a system write is judged as before.
- secret reference: the row's own reference, another row's and one
naming nothing get one answer, by id and in bulk, for a caller who can
and who cannot read the column; controls: the mask echo and a new value,
and the system pass-through.
- `packages/objectql/src/secret-field-own-ref-write.test.ts` pinned the
old keep of a record's own reference. It now pins the rule above: the
record's own reference is refused exactly as another's, by id and on a
predicate update, and a `beforeUpdate` hook that spreads the stored row
back is judged on the reference it carries (one that omits the `secret`
column lands).
- `packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.ts`
pinned the same keep over REST and now pins the same refusal.
- `packages/objectql/src/validate-preview-parent.test.ts`, a new
top-level describe: for a caller not served the FK, preview and write
give the same `required` verdict whatever header the line hangs off;
control: a caller served the FK keeps the legacy rest and the repoint
refusal, in both.
## Acceptance notes
- No shipped flow, seed, example or console path sends a `secret:`
reference from a non-system write: every in-repo writer of a `secret`
column (webhooks, SSO client secret, connector connections and
credentials, flow credentials) writes under a system context, the CLI
ownership apply works below the engine, and no shipped hook writes
`ctx.previous` back into an update payload. The one in-repo reliance was
the two suites above.
- A third-party `beforeUpdate` hook that spreads `ctx.previous` into the
payload on an object with a `secret` field is now refused for a
non-system caller; the changeset names the fix (omit the `secret`
columns).
- The controlled-by-parent write gate (plugin-security, ADR-0055 §3)
authorizes a detail write against the master its stored row hangs off,
read as stored. That is the authorization model itself, not a comparison
of a sent value with the stored row, so this PR does not change it.
- On a driver that leaves an unset FK out of the stored row entirely, a
non-system caller who names the FK now has the prior header bound as
unresolved, as for a hidden FK; a driver that stores the FK as `null` is
judged as before.
## Verification
<details><summary>Ablations (each committed first, mutated through
scripts/ablation-replace.mjs, restored to HEAD and verified
empty)</summary>
- Secret keep compared against the stored rows as read: objectql
own-reference suite 4 failed / 16 passed; security suite 4 failed / 40
passed (by id and bulk, for both grants). Restored: blob equals HEAD,
`git diff HEAD` empty.
- By-id prior FK read off the stored row: security suite 1 failed / 43
passed (the by-id hidden-FK pin: the line resting under the stored
header was written). Restored as above.
- Bulk prior FK read off the stored rows: security suite 1 failed / 43
passed (the bulk hidden-FK pin). Restored as above.
- Preview prior header bound as before (no unknown arm): preview pin
file 1 failed / 23 passed (the preview admitted what the write refused).
Restored as above.
</details>
<details><summary>Suites and gates</summary>
Code readings are on `f49cf38154`; the final head `82a9a6bbda` adds only
one changeset sentence, and the changeset gates were re-run on it.
- `pnpm --filter @objectstack/objectql test`: Test Files 563 passed
(563), Tests 11361 passed (11361). `typecheck` and
`check:test-typecheck` OK.
- `pnpm --filter @objectstack/plugin-security test`: Test Files 248
passed (248), Tests 5773 passed, 45 skipped. `typecheck` and
`check:test-typecheck` OK.
- Full dogfood battery, `pnpm --filter @objectstack/dogfood test` under
the verify lock: VERDICT command-exit 0, Test Files 430 passed, 3
skipped (433); Tests 3572 passed, 41 skipped (3613).
- Derived gates (`dispatch-gates --commands`, 10 paths): 97 derived, 95
exit 0. `check:type-check-debt` first exited 3 (prerequisite) and passed
on a rerun after its own full build. Two stay NOT MEASURED locally, exit
3, prerequisite not met (a full build): `spec check:skill-examples` and
`check:dual-build-cjs-loads`; CI runs both. `dispatch-gates --ran` with
exit codes: 97 accounted, 0 unrun.
- Re-run on `82a9a6bbda`: `check-adr-0087-registration` (the changeset
reads as breaking, clause-② narrowing, not-required
no-migration-prescription), `check-changeset-no-major`,
`check-empty-changeset`, `check:nul-bytes`, `check:doc-authoring`, all
exit 0.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 23cf11a commit cad764d
10 files changed
Lines changed: 508 additions & 68 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- objectql/src
- plugins/plugin-security/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
141 | | - | |
| 141 | + | |
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
57 | 62 | | |
58 | 63 | | |
59 | 64 | | |
| |||
70 | 75 | | |
71 | 76 | | |
72 | 77 | | |
| 78 | + | |
73 | 79 | | |
74 | 80 | | |
75 | 81 | | |
| |||
248 | 254 | | |
249 | 255 | | |
250 | 256 | | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
251 | 265 | | |
252 | 266 | | |
253 | 267 | | |
| |||
290 | 304 | | |
291 | 305 | | |
292 | 306 | | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
293 | 314 | | |
294 | 315 | | |
295 | 316 | | |
| |||
334 | 355 | | |
335 | 356 | | |
336 | 357 | | |
337 | | - | |
| 358 | + | |
338 | 359 | | |
339 | 360 | | |
340 | 361 | | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
341 | 402 | | |
342 | 403 | | |
343 | 404 | | |
| |||
0 commit comments