Repository navigation
Commit d6b88f1
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: updated here —
`content/docs/permissions/attachments-access.mdx` and
`content/docs/api/plugin-endpoints.mdx` state the `attachTo` key of the
upload-complete request and its refusals; no file under `skills/**`
teaches the upload-complete request (a `git grep` of `skills/` finds no
`upload/complete` and no `CompleteUploadRequest`).
## 维护者速读
- 改了什么:记录附件面板在"完成上传"的同一次调用里,以成员本人身份把文件挂到记录上(`attachTo`),不再走第二步通用数据接口。
- 为什么改:#31031 之后,没有 `sys_file` 读权限的成员从浏览器挂附件,第二步 `POST
/data/sys_attachment` 被拒(400 `reference_not_found`)。
- 风险与回滚:担保只覆盖调用者本人上传、仍为
pending、尚未被任何记录持有的那一个文件,并且先过挂载授权;通用数据接口照旧拒绝;担保在本次写入后即失效,钩子拿不到。回滚 =
revert 本 PR,面板回到被拒状态。
- 先看:`file-attachment-service.ts` 的
`attachUploadAs`(担保前的各道检查)、`storage-routes.ts` 完成上传门的 `attachTo`
分支、`engine-defaults-and-autonumbers.ts` 的 `buildHookApi`(剥除担保)。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/attachments-browser-attach.dogfood.test.ts`(先构建依赖闭包),预期 8 passed。
- 需要你做的:无,席位落地。
## What changes
**The rule.** The upload-complete door (`POST
/api/v1/storage/upload/complete`) takes an optional `attachTo: {
parentObject, parentId }`. With it, the door first runs every gate it
already ran: the session, a malformed `attachTo` (400), an id that names
no file (404), the upload ownership rule (403), the starting
organization, and the held bytes (409). It then hands the upload to the
file-attachments service's new `attachUploadAs`, running as the caller.
The answer is the committed file plus its new `attachmentId`.
`attachUploadAs` vouches for one `sys_file` and nothing else. Every
condition below must hold, in this order, before the vouch is spent:
- The file is the caller's own upload. Another user's id and an id that
names no file get the same answer.
- It is `pending`.
- It is in the `attachments` scope.
- It is within the caller's write reach.
- No record holds it yet (`findFileHolder`: no `sys_attachment` row and
no `ref_*` column).
- Its bytes are held.
- The caller may attach to the parent: the `sys_attachment` create
permission, the parent-edit attach rule, and `enable.files`.
- The parent is in the file's organization.
The verb then writes the `sys_attachment` row (vouching for that
`file_id`) and commits the file, as one unit. If the write fails, the
upload stays `pending`.
**One-shot vouch.** The vouch is spent on the verb's own write.
`buildHookApi` builds every hook's `ctx.api`, plus `withRunAs('user')`
and `sudo` derived from it, from a context with the mark stripped
(`withoutVouchedReferences`, `@objectstack/core`). A hook running under
the vouched insert that names the same file is judged through the
caller's read door.
**Protocol.** The spec's `CompleteUploadRequestSchema` gains the
optional `attachTo` key, and `FileUploadResponseSchema` gains an
optional `attachmentId`. So `Metadata-protocol:` is `added`, and
`@objectstack/spec` is graded `minor`.
**Unchanged.**
- No caller gains a read of `sys_file`.
- `POST /api/v1/data/sys_attachment` naming a `sys_file` the caller
cannot read is still refused `reference_not_found`.
- A completion without `attachTo` commits exactly as before.
- A mount with no file-attachments service refuses `attachTo` with 501.
**Console.**
- The Attachments panel completes its upload with `attachTo` and makes
no second call. It keeps its refusal messages.
- The upload adapter gains `attachTo` and returns the `attachmentId`.
**Why the complete door, not a new route.**
- `pending` is the proof that the file came from this flow and was never
attached.
- The commit and the attach are one unit, so no committed-but-unattached
file is left behind.
- The panel already calls this door, so its flow loses a step instead of
gaining one.
**Other paths checked.**
- The SDK's `storage.upload` has no attach step.
- MCP has no attach tool.
- No other console path creates `sys_attachment`.
None of them needed a change.
**Docs.**
- `content/docs/permissions/attachments-access.mdx`
- `content/docs/api/plugin-endpoints.mdx`
- the service-storage README
- the generated storage API reference
**Recounted.** The tenant audit census counts the verb's new
`sys_attachment` insert (265 to 266 write call sites), and the
strictness ledger counts the new `attachTo` object.
## Tests
- `file-attachment-service.test.ts`, 13 new `attachUploadAs` cases.
- Green leg: a member without `sys_file` read attaches. The file is
committed, `uploaded_by` is the member, and `sys_file` stays unreadable
to them.
- Refused:
- another user's id and a nonexistent id (same answer),
- a committed upload, an already held upload,
- the `user` scope, missing bytes,
- a sharing deny, an explain deny,
- a parent without `enable.files`, an unreadable parent,
- a system context.
- After the verb, the member's own direct insert naming the same file is
still refused `reference_not_found`.
- `storage-routes.complete-attach.test.ts` (new, 15 cases).
- The door's own gates run first, and a refused request never reaches
the attacher: 401, 403, 404, 409, and 400 for five malformed `attachTo`
shapes.
- The attacher's declared refusals are relayed with their code. A
validation refusal becomes `VALIDATION_FAILED`. An undeclared fault is a
generic 500.
- A mount with no attacher answers 501.
- A completion without `attachTo` is unchanged.
- `engine-unreadable-reference-target.test.ts`, 3 new cases. A hook
writing through `ctx.api`, or through `ctx.api.withRunAs('user')`, under
the vouched insert, naming the same unreadable target, is refused. The
control: under a system write, the same hook's write lands.
- `attachments-browser-attach.dogfood.test.ts` (new, 8 cases), at the
HTTP level on the showcase boot, as a member holding
`showcase_contributor` plus `sys_attachment` read/create and nothing on
`sys_file`.
- Green leg: presign, then PUT, then complete with `attachTo` gives 200.
One committed file, one attachment in the member's name. The panel's
list read and the download URL work, and `sys_file/:id` stays 403.
- Controls:
- the generic two-step is refused 400 `reference_not_found`;
- another user's upload id is refused 403 and stays pending;
- a nonexistent id gets 404;
- another user's invoice is refused and the upload stays pending;
- re-attaching gets 409;
- a `'*'` reader works through both flows.
- Console:
- `RecordAttachmentsPanel.test.tsx` uses the real adapter with a stubbed
fetch. The complete body carries `attachTo`, a refusal maps to friendly
copy, and a missing `attachmentId` is an error.
- The `affordanceGrant-12047` control asserts the adapter gets
`attachTo` and that `dataSource.create` is never called.
- The write census in `affordanceGrantMap-12082.test.tsx` drops the
panel's `handleFiles :: create` site, since the panel no longer calls
`create`. Its `attachmentUpload` row is still read where the upload is
offered, and its delete site is unchanged.
## Acceptance notes
- The order's controls are all pinned in the dogfood:
- another user's id,
- a nonexistent id,
- no attach right on the parent,
- the direct generic POST still refused,
- the `'*'` reader working.
- The vouch premise is pinned in the unit tests: pending, the caller's
own upload, through this flow, not yet attached.
- One-shot: pinned in objectql; red under the ablation below.
- The door answers 404 for an id that names no file and 403 for another
user's id. That distinction predates this change: the door's existing
`requireUploader` path already makes it on completion without
`attachTo`. The verb's own answer is identical for both.
**Follow-up.** The call-site census gate
(`check:vouch-for-reference-callers`) has moved out of this PR. It pins
the code that sets the vouched-reference mark to an allowlist:
`file-attachment-service.ts`, 2 calls. Its parts:
- the script and its root alias,
- a core `repo` vitest pin,
- the Test Core input wiring,
- a `lint.yml` step.
It lands in its own PR after this one merges. That PR is Tier H, through
the maintainer, because of the workflow step. This PR touches no
governed surface.
## Verification
<details><summary>Ablation: the vouch in attachUploadAs</summary>
The verb's insert context `vouchForReference(unitContext as
ExecutionContext, …)` was replaced with a plain `unitContext` through
`scripts/ablation-replace.mjs`. service-storage was rebuilt, and
`ablation-dist-preflight` confirmed the marker is present in 2 built
files.
- Unit (`-t attachUploadAs`): **2 failed, 11 passed**. Red: the member
attach, and the case that first attaches through the verb and then tries
a direct insert. Its setup attach now fails.
- Dogfood: **2 failed, 6 passed**. Red: the member end-to-end attach,
and the re-attach control, whose first completion must succeed. Green:
the generic door refusal, another user's id, the nonexistent id, another
user's invoice, and the `'*'` reader through both flows.
- Restore: blob == HEAD (`24e738f31e20`), source clean. Rebuilt, and
preflight `--absent` confirmed the marker is absent from all 6 built
files.
</details>
<details><summary>Ablation: the strip in buildHookApi</summary>
`withoutVouchedReferences(...)` was removed from `buildHookApi`. Result:
`engine-unreadable-reference-target.test.ts` **2 failed, 122 passed**.
Red: the `ctx.api` and `withRunAs('user')` hook cases. Green: the
system-write control. Restore: blob == HEAD.
</details>
<details><summary>Suites at head cb31937 (main merged at 5210acf),
under the verify lock</summary>
- `pnpm install --frozen-lockfile && pnpm build`: 119 of 119 tasks.
- `@objectstack/core`: test 3143 passed; `test:repo` 94 passed.
- `@objectstack/objectql`: `engine-unreadable-reference-target.test.ts`
124 passed. The whole package (11372 passed) was run before the census
gate was split out; that commit does not touch objectql.
- `@objectstack/service-storage`: test 1388 passed; typecheck clean.
- Dogfood:
- files: `attachments-browser-attach`, `attachments-permission-matrix`,
`print-service-archive`.
- result: 46 passed, 1 skipped.
- the skip is the existing `skipIf(!organizationsAvailable)` block.
- UI:
- `affordanceGrantMap-12082.test.tsx`: 55 passed.
- `plugin-form`, `app-shell` and `data-objectstack` whole packages:
23858 passed, 10 skipped.
- `turbo run type-check` for `@object-ui/app-shell` and
`@object-ui/data-objectstack`: 68 of 68 tasks.
- `@objectstack/spec` `check:generated`: all 14 artifacts current.
- The full dogfood suite was not run: the title has no `!` and Clause-②
carries no `(narrowing)` arm.
</details>
<details><summary>Gates</summary>
- `dispatch-gates --commands` at cb31937 derives 117 commands, and
all 117 were run and exited 0. `--ran` reconciles them: 117 derived, 117
run, 0 not measured, 0 unrun.
- The three that need the verify lock ran under it, exactly as printed:
`pnpm --dir ui exec vitest run scripts/`, `spec check:generated` and
`check:type-check-debt`.
- `check:self-test-wired`: green.
- `check-governed-merges.mjs --pr 31072`: NOT governed. 0 of 26 paths
hit the register, and `git diff origin/main...HEAD -- .github/` is
empty.
- The changeset gates were run against the merge base 5210acf:
`check-changeset-no-major`, `check-adr-0087-registration` (non-breaking)
and `check-empty-changeset`, all green. `check-changeset-no-major` was
also fed a `pull_request` payload carrying this body: its level axis
passes, reading clause-② `yes (widening)`, metadata-protocol `added`,
and `@objectstack/spec: minor`.
</details>
https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb8f520 commit d6b88f1
26 files changed
Lines changed: 1449 additions & 119 deletions
File tree
- .changeset
- content/docs
- api
- permissions
- references/api
- docs/audits
- 2026-07-unknown-key-strictness-ledger.counts
- packages
- core/src/security
- objectql/src
- qa/dogfood/test
- services/service-storage
- src
- spec
- authorable-surface
- src/api
- ui/packages
- app-shell/src/views
- __tests__
- data-objectstack/src
- plugin-form/src
| 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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
154 | 154 | | |
155 | 155 | | |
156 | 156 | | |
157 | | - | |
| 157 | + | |
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
| |||
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
170 | 177 | | |
171 | 178 | | |
172 | 179 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
75 | 108 | | |
76 | 109 | | |
77 | 110 | | |
| |||
224 | 257 | | |
225 | 258 | | |
226 | 259 | | |
| 260 | + | |
227 | 261 | | |
228 | 262 | | |
229 | 263 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
93 | 93 | | |
94 | 94 | | |
95 | 95 | | |
96 | | - | |
| 96 | + | |
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | | - | |
203 | | - | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
210 | 210 | | |
211 | 211 | | |
212 | 212 | | |
213 | | - | |
| 213 | + | |
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
| 236 | + | |
| 237 | + | |
238 | 238 | | |
239 | | - | |
| 239 | + | |
240 | 240 | | |
241 | | - | |
| 241 | + | |
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | 252 | | |
253 | | - | |
| 253 | + | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
| 257 | + | |
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
106 | 114 | | |
107 | 115 | | |
108 | 116 | | |
| |||
211 | 219 | | |
212 | 220 | | |
213 | 221 | | |
| 222 | + | |
214 | 223 | | |
215 | 224 | | |
216 | 225 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| |||
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
273 | | - | |
| 273 | + | |
274 | 274 | | |
275 | 275 | | |
276 | 276 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
544 | 544 | | |
545 | 545 | | |
546 | 546 | | |
| 547 | + | |
547 | 548 | | |
548 | 549 | | |
549 | 550 | | |
| |||
0 commit comments