You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1fd68ea
Browse filesBrowse the repository at this point in the historyBrowse files
feat(service-storage): sys_attachment verifies the caller may open the referenced file (#27424)
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:`sys_attachment` 的写入闸门现在会核验调用者能否打开 `file_id` 指向的文件(插入时,以及更新改动
`file_id` 时),与文件字段写入共用同一个判定。
- 为什么改:附件是 attachments 范围文件经父记录变得可读的途径;引用文件的两扇写入门应当回答同一个问题,由同一个判定回答。
- 风险与回滚:风险在于误拒合法的附件写入(上传者本人、或已能经其他记录打开该文件的人仍放行;系统写不判)。回滚即 revert 本 PR。
-
先看:`packages/services/service-storage/src/attachment-access-hooks.ts`(`installAttachmentFileReferenceGate`)、`packages/services/service-storage/src/referenceable-file.ts`、`packages/services/service-storage/src/storage-service-plugin.ts`(接线)。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/attachments-permission-matrix.dogfood.test.ts -t 'names a file the
caller may open'`,预期 3 个用例全绿。
- 需要你做的:无,席位落地。
## What changes
`sys_attachment` now verifies that the caller may open the file a row
references.
- **One predicate, shared.** `findReferenceableFile`,
`systemReadContext`, the `FileReferenceReadAuthorizer` type and the
refusal sentence move from `file-reference-lifecycle.ts` into a new
module, `referenceable-file.ts`. The file-field door keeps using them
unchanged (byte-identical refusal text); the attachment gate now uses
the same ones.
- **The attachment write gate asks it.** A new registration,
`installAttachmentFileReferenceGate` (in `attachment-access-hooks.ts`,
not exported from the package entry), adds a `beforeInsert` and a
`beforeUpdate` hook on `sys_attachment`. Insert: the row's `file_id`
must name a file the caller may open. Update: asked once when the
payload names a `file_id` that differs from a matched row's.
System-context and context-less writes are not judged, exactly as at the
other gates in that module.
- **Same answer as the file-field door.** A file the caller may not
open, and an id that names no file the acting organization reaches, are
answered alike: `400 VALIDATION_FAILED`, one ADR-0114 `invalid_value`
entry for `file_id` labelled with the field's declared label (`File`).
Nothing is written.
- **Order.** Installed right after `installAttachmentAccessHooks`
(same-priority hooks run in registration order), so the parent rule and
the row rule answer first and a caller they refuse keeps their refusal.
- **Wiring.** `storage-service-plugin.ts` builds the download door's
verdict (`buildFileReferenceReadAuthorizer`) once and hands the same
binding to both doors. `installAttachmentAccessHooks`'s exported
signature is unchanged.
- What keeps working: attaching your own upload (including
`attachFileAs`, whose fresh upload is the caller's own), attaching a
file you can already open through another record, admin attaching their
own files, seeds and lifecycle sweeps.
## Tests
- Unit (`attachment-access-hooks.test.ts`, new describe "the file a row
names must be one the caller may open", driven through the suite's
existing engine double): insert refuses a file the caller cannot open
(envelope: `code` + the `file_id` field entry, lookup carries the acting
organization); an id naming no file gets the same answer; controls: an
openable file is admitted, a system insert is not judged, a caller the
parent rule refuses keeps `ATTACHMENT_PARENT_ACCESS` with no file
judged, no file gate installed means no file judged; update refuses a
`file_id` change to an unopenable file, admits an openable one, and
judges nothing when `file_id` is unchanged or absent.
- `storage-service-plugin.test.ts`: the hook-roster pin now lists the
second `beforeInsert`/`beforeUpdate` pair, with a comment naming it.
- Dogfood, HTTP in process
(`attachments-permission-matrix.dogfood.test.ts`, new describe `(h)`):
insert of a file the member cannot open answers 400 `VALIDATION_FAILED`
with the `file_id` entry, no row lands, the member's download of that
file still answers 403; control: the member's own upload attaches.
Update re-pointing `file_id` at that file answers the same 400 and
leaves the row unchanged; control: re-pointing at the member's second
own upload answers 200. Control: the file's owner attaches it.
## Acceptance notes
- `Clause-②: no`: a runtime permission behaviour change, no contract
surface widened (the new installer is module-level, not reachable from
the package entry; `installAttachmentAccessHooks` keeps its signature).
- The update hook judges the payload's `file_id` once per dispatch; the
per-row dispatch binds `input.id`, and the unscoped multi-update shape
is refused by the row rule before this hook runs.
## Verification
All readings at head `bc7274ef` (objectstack-ai/objectstack).
<details><summary>Package tests, typecheck, lint</summary>
- `pnpm --filter @objectstack/service-storage test`: Test Files 70
passed (70), Tests 1267 passed (1267).
- `pnpm --filter @objectstack/service-storage typecheck`: exit 0.
- `eslint --no-inline-config packages/services/service-storage/src
packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts`:
exit 0.
- Lock verdict: `os-verify-lock: VERDICT command-exit 0`.
</details>
<details><summary>Dogfood</summary>
- `test/attachments-permission-matrix.dogfood.test.ts` (dependency
closure built first): Tests 22 passed, 1 skipped (23).
- Every dogfood file that writes or reads `sys_attachment` or the
storage file routes (21 files): Test Files 20 passed, 1 skipped (21);
Tests 177 passed, 9 skipped (186).
</details>
<details><summary>Ablation (one-time proof, not kept)</summary>
- Mutation: in `storage-service-plugin.ts`, the
`installAttachmentFileReferenceGate(...)` call replaced through
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob `00a7d069` to
`50b2de31`), package rebuilt, `ablation-dist-preflight.mjs --absent`
confirmed the call absent from all 4 built files.
- Mutated run of the `(h)` cases: the two refusal cases failed on their
`400` expectation, the owner control passed.
- Restore: `git checkout HEAD -- PATH`, rebuild, preflight confirmed the
call back in `dist/`, blob hash equals HEAD (`00a7d069`), working tree
clean.
</details>
<details><summary>Gate families</summary>
- `node scripts/pm/dispatch-gates.mjs --commands` (95 families) run
locally; `--ran` reconciliation: 95 derived, 95 run, 0 UNRUN.
- Exit 0 for all but two, both NOT MEASURED:
`check-plugin-teardown-shape --self-test` (exit 3, its pinned fixture
commit is outside this shallow checkout) and
`check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: unrelated
packages have no `dist/` here). CI runs both.
- `check:system-context-census` and `check:engine-double-contract` were
red on an earlier head and are green at this head (the register row
names the new installer; the new unit cases reuse the suite's existing
engine double).
- `node scripts/check-platform-checklist.mjs --changed-from
origin/main`: exit 0, 0 anchors lost.
- `check:nul-bytes`: OK.
</details>
Dispatched by `domain:devx#1 · sunhaolin ·
session_017xRBcScm27Q3R5mkYLPWdB`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(service-storage): `sys_attachment` verifies the caller may open the file a row references
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
An attachment links an `attachments`-scope file to a parent record, and the download door admits a caller through any parent record the file is attached to. The `sys_attachment` write gate now asks, of every insert and of every update that changes `file_id`, the same question a file-class field asks before it references a file: may this caller open the file it names?
11
+
12
+
-**One predicate, two write doors.** The check is the one the file-field door already runs (`findReferenceableFile`, bound by the storage plugin to the download door's own verdict), now shared by both doors from `referenceable-file.ts`, so a field reference and an attachment reference cannot disagree about which files a caller may take.
13
+
-**The refusal is the file-field door's.** A file the caller may not open, and an id that names no file the acting organization reaches, are answered alike: `400 VALIDATION_FAILED` with one `invalid_value` entry for `file_id` ("File cannot reference this file: it is not a file you can open. Choose a file you can open, or upload it again."). Nothing is written.
14
+
-**What keeps working.** Attaching a file you uploaded, or one you can already open through another record, is admitted as before, as is `attachFileAs`, which attaches the caller's own fresh upload. System-context and context-less writes (seeds, engine self-writes, lifecycle sweeps) are not judged. The parent and row rules still answer first, so a caller they refuse keeps their refusal. A kernel that composes no download gate serves every file, and so every file may be referenced there.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -173,7 +173,7 @@ One of the largest consumers of the flag;
173
173
| 41 |**Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem`|`bindApprovalLockHook`|
174
174
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator |`bindDelegationWriteGuard`|
175
175
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter |`isOverrideActor`, `resolveActor`, `sendBack`, `resubmit`, `reassign`, `remind`, `requestInfo`, `comment`|
176
-
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping |`installAttachmentAccessHooks`, `installAttachmentReadVisibility`|
176
+
| 44 | Attachment access hooks return early (insert + update + delete, the referenced-file check on insert + update, and the read AST) | service-storage | Lose: attachment visibility scoping |`installAttachmentAccessHooks`, `installAttachmentFileReferenceGate`, `installAttachmentReadVisibility`|
177
177
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), the activity summary's message code (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, and the activity row's stored summary code (`metadata.summary_message`), on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `installCommentAccessHooks`, `installCommentReadVisibility`, `installActivityReadVisibility`, `installAuditLogReadVisibility`, `installActivityFieldRedaction`, `installAuditLogFieldRedaction`, `installActivitySummaryLocale`, `installParentFieldQueryGuard` |
178
178
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results |`applyPermissionFilter`|
179
179
| 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) |`gateMiddleware`|
@@ -651,6 +651,7 @@ and CI refuses one that differs from what the tree produces.
0 commit comments