Skip to content

Commit a5302c7

Browse files
os-helpclaude
andauthored
fix(service-storage): refuse a predicate update that writes a file field (#7102) (#7224)
A predicate (`multi: true`) update has ONE payload for N matched rows, so a file id written through one landed in every matched record while at most one of them could own it — read authorisation for those bytes then derived from a record the others have nothing to do with, which is the exact widening the exclusive-ownership design exists to prevent. Two log warnings were the only signal and nothing failed. That write is now refused in `beforeUpdate`, before the driver runs, with an ADR-0112 envelope error (`FILE_FIELD_BULK_WRITE_REFUSED` / 400). The refusal is scoped to a file id TOKEN reaching a file-class field — decided by `isFileIdToken`, the same arbiter copy-on-claim already uses — so a bulk clear, an external URL and a legacy inline blob still work per row, and every single-record path is byte-identical. Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f525b9 commit a5302c7

15 files changed

Lines changed: 480 additions & 77 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(service-storage): a predicate update writing a file field is refused, instead of giving N records one file id (#7102)
7+
8+
`file-reference-lifecycle.ts` states **exclusive ownership** in its module
9+
header: at most one `(object, record, field)` slot owns a `sys_file`, so
10+
copying an already-owned id into a second slot copies the bytes rather than
11+
sharing the row. The property that buys is that read authorisation for a
12+
file's bytes derives from exactly one parent record — writing a private
13+
record's file id into a world-readable one cannot widen who can read it.
14+
15+
**Before.** A predicate update
16+
(`engine.update(obj, { avatar: 'fileX' }, { multi: true, where: … })`) had one
17+
payload for N matched rows — `driver.updateMany` takes one `SET` clause — so
18+
`beforeUpdate` resolved ONE copy and the driver wrote it to **all** matched
19+
records. `afterUpdate` then claimed it for the first row; `claimFile` never
20+
steals, so the rest logged `already owned by …` and moved on. Three matched
21+
records ended up referencing one file that one of them owned, with read
22+
authorisation for those bytes decided by a third record — exactly the
23+
widening the design exists to prevent. Two log warnings were the only signal,
24+
and nothing failed.
25+
26+
**After.** That write is refused, in `beforeUpdate`, before the driver runs:
27+
28+
```
29+
FILE_FIELD_BULK_WRITE_REFUSED / 400 (FileFieldBulkWriteError)
30+
```
31+
32+
an ADR-0112 envelope error carrying a registered `code` and a 4xx `status`, so
33+
the REST layer answers `400` rather than promoting a bare `Error` to a `500`.
34+
Nothing is written, nothing is copied, and no `sys_file` row is read. The
35+
remedy is the caller's: update each record separately, so each one gets a file
36+
it owns.
37+
38+
**Scope of the refusal.** It fires only when a file **id token** reaches a
39+
file-class field through a predicate update — the one shape that produces the
40+
shared id, decided by `isFileIdToken`, the same arbiter copy-on-claim and the
41+
read resolver already use. Three predicate writes that own nothing are
42+
deliberately unaffected and keep working per row: clearing a file field
43+
(`{ avatar: null }`), writing an external URL, and writing a legacy inline
44+
blob — each releases the file its own row's slot owned. Single-record updates,
45+
inserts and every delete path are byte-identical to before.
46+
47+
`FILE_FIELD_BULK_WRITE_REFUSED` is registered in `@objectstack/spec`'s
48+
`ERROR_CODE_LEDGER` under `@objectstack/service-storage` (ADR-0112 D3), so the
49+
code is a catalogued wire value rather than an unregistered string the REST
50+
layer would mint by side effect.

‎content/docs/references/api/analytics.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data);
4444
| Property | Type | Required | Description |
4545
| :--- | :--- | :--- | :--- |
4646
| **success** | `boolean` | ✅ | Operation success status |
47-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
47+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
4848
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
4949
| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
5050

@@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data);
7979
| Property | Type | Required | Description |
8080
| :--- | :--- | :--- | :--- |
8181
| **success** | `boolean` | ✅ | Operation success status |
82-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
82+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
8383
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
8484
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string }` | ✅ | |
8585

@@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data);
9393
| Property | Type | Required | Description |
9494
| :--- | :--- | :--- | :--- |
9595
| **success** | `boolean` | ✅ | Operation success status |
96-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
96+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
9797
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
9898
| **data** | `{ sql: string; params: any[] }` | ✅ | |
9999

‎content/docs/references/api/auth.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ const result = AuthProvider.parse(data);
117117
| Property | Type | Required | Description |
118118
| :--- | :--- | :--- | :--- |
119119
| **success** | `boolean` | ✅ | Operation success status |
120-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
120+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
121121
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
122122
| **data** | `{ session: object; user: object; token?: string }` | ✅ | |
123123

@@ -153,7 +153,7 @@ const result = AuthProvider.parse(data);
153153
| Property | Type | Required | Description |
154154
| :--- | :--- | :--- | :--- |
155155
| **success** | `boolean` | ✅ | Operation success status |
156-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
156+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
157157
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
158158
| **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | |
159159

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data);
119119
| Property | Type | Required | Description |
120120
| :--- | :--- | :--- | :--- |
121121
| **success** | `boolean` | ✅ | Operation success status |
122-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
122+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
123123
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
124124
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition |
125125

@@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data);
144144
| Property | Type | Required | Description |
145145
| :--- | :--- | :--- | :--- |
146146
| **success** | `boolean` | ✅ | Operation success status |
147-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
147+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
148148
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
149149
| **data** | `{ name: string; deleted: boolean }` | ✅ | |
150150

@@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data);
187187
| Property | Type | Required | Description |
188188
| :--- | :--- | :--- | :--- |
189189
| **success** | `boolean` | ✅ | Operation success status |
190-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
190+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
191191
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
192192
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition |
193193

@@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data);
213213
| Property | Type | Required | Description |
214214
| :--- | :--- | :--- | :--- |
215215
| **success** | `boolean` | ✅ | Operation success status |
216-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
216+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
217217
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
218218
| **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details |
219219

@@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data);
241241
| Property | Type | Required | Description |
242242
| :--- | :--- | :--- | :--- |
243243
| **success** | `boolean` | ✅ | Operation success status |
244-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
244+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
245245
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
246246
| **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
247247

@@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data);
269269
| Property | Type | Required | Description |
270270
| :--- | :--- | :--- | :--- |
271271
| **success** | `boolean` | ✅ | Operation success status |
272-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
272+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
273273
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
274274
| **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
275275

@@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data);
295295
| Property | Type | Required | Description |
296296
| :--- | :--- | :--- | :--- |
297297
| **success** | `boolean` | ✅ | Operation success status |
298-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
298+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
299299
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
300300
| **data** | `{ name: string; enabled: boolean }` | ✅ | |
301301

@@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data);
325325
| Property | Type | Required | Description |
326326
| :--- | :--- | :--- | :--- |
327327
| **success** | `boolean` | ✅ | Operation success status |
328-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
328+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
329329
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
330330
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | |
331331

@@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data);
351351
| Property | Type | Required | Description |
352352
| :--- | :--- | :--- | :--- |
353353
| **success** | `boolean` | ✅ | Operation success status |
354-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
354+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
355355
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
356356
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition |
357357

‎content/docs/references/api/batch.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data);
5555
| :--- | :--- | :--- | :--- |
5656
| **id** | `string` | optional | Record ID if operation succeeded |
5757
| **success** | `boolean` | ✅ | Whether this record was processed successfully |
58-
| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). |
58+
| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). |
5959
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
6060
| **index** | `number` | optional | Index of the record in the request array |
6161
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |
@@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data);
122122
| Property | Type | Required | Description |
123123
| :--- | :--- | :--- | :--- |
124124
| **success** | `boolean` | ✅ | Operation success status |
125-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +257 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
125+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +258 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
126126
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
127127
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed |
128128
| **total** | `number` | ✅ | Total number of records in the batch |

0 commit comments

Comments
 (0)