Skip to content

Commit a23c32e

Browse files
os-zhuangclaude
andauthored
docs(permissions): Attachments Access page + ADR-0057 sys_upload_session guard note (#3009)
Documents the attachment access model landed across #2984/#2999/#2538 (#2970): parent-derived read/create/delete, inherited list visibility, authenticated parent-scoped downloads, the enable.files opt-in gate, the member_default no-delete-baseline caveat, the storage-byte lifecycle (sys_file + sys_upload_session reap guards), and an enforcement-summary + error-code table. Registered in permissions/meta.json. Also names sys_upload_session as the second reap-guard consumer in the ADR-0057 amendment. Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt Co-authored-by: Claude <noreply@anthropic.com>
1 parent a81b1a9 commit a23c32e

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
title: Attachments Access
3+
description: How access to record attachments is decided — the parent-derived read/create/delete model, authenticated downloads, the enable.files opt-in gate, and the storage-byte lifecycle. Covers sys_attachment and sys_file.
4+
---
5+
6+
# Attachments Access
7+
8+
The generic **Attachments** surface (Salesforce "Notes & Attachments" parity)
9+
separates *file storage* from *where a file is attached*:
10+
11+
- **`sys_file`** — one row per uploaded blob (the bytes live in the storage
12+
backend; the row holds `key`, `scope`, `owner_id`, `status`).
13+
- **`sys_attachment`** — a polymorphic join row linking a `sys_file` to any
14+
record via `parent_object` + `parent_id` (like Salesforce
15+
`ContentDocumentLink`). One file can be attached to many records.
16+
17+
The governing principle: **an attachment has no access model of its own — it
18+
inherits its parent record's.** A caller who can read a record can read its
19+
attachments; a caller who can edit a record can attach to and detach from it.
20+
Enforcement is layered, and every gate is fail-closed.
21+
22+
`Field.file` / `Field.image` are a **separate** path — those store a file URL
23+
in the record's own column and never create a `sys_attachment` row, so nothing
24+
on this page applies to them.
25+
26+
## The opt-in gate — `enable.files`
27+
28+
Attachments are **opt-in per object** (spec default `false`). A `sys_attachment`
29+
row may only target an object that declares `enable: { files: true }`; the
30+
Attachments panel renders only for such objects. Any other target is rejected:
31+
32+
| Code | Status | When |
33+
| --- | --- | --- |
34+
| `FILES_DISABLED` | 403 | Creating a `sys_attachment` whose `parent_object` does not declare `enable.files: true` |
35+
36+
This is enforced by a `beforeInsert` hook on `sys_attachment` and is
37+
independent of the record-level checks below.
38+
39+
## Create — read visibility (+ edit-on-parent) & provenance
40+
41+
Creating a `sys_attachment` requires that the caller can **read** the parent
42+
record (verified with a caller-scoped `findOne`, so RLS / OWD / sharing of the
43+
parent object apply), and — for edit-on-parent parity — that they can edit it.
44+
`uploaded_by` is **server-stamped** from the session; a client-supplied value
45+
is ignored.
46+
47+
| Code | Status | When |
48+
| --- | --- | --- |
49+
| `ATTACHMENT_PARENT_ACCESS` | 403 | The caller cannot access the parent record they are attaching to |
50+
51+
## Read / list — inherited parent visibility
52+
53+
Listing or reading `sys_attachment` only returns rows whose **parent record the
54+
caller can read**. This is enforced by a `sys_attachment`-scoped engine
55+
*middleware* (not a hook), so it filters `find`, `findOne`, `count`, and
56+
`aggregate` identically — the list `total` cannot leak the count of hidden
57+
rows. Per distinct `parent_object`, the visible parent ids are resolved through
58+
the caller-scoped engine (the parent object's own RLS applies) and folded into
59+
the query; a caller with no visible parent gets an empty result. Failure to
60+
resolve the filter fails closed (deny-all).
61+
62+
## Delete — uploader or parent editor
63+
64+
Deleting a `sys_attachment` is allowed when the caller is **the uploader** OR
65+
**can edit the parent record** (`sharing.canEdit` — public-model parents are
66+
editable by design). A multi-delete requires *every* matched row to pass.
67+
68+
| Code | Status | When |
69+
| --- | --- | --- |
70+
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller is neither the uploader nor able to edit the parent record |
71+
72+
<Callout type="info">
73+
The platform baseline permission set (`member_default`, the `everyone`
74+
anchor) grants **no delete** on `sys_attachment` (ADR-0090 D5 — delete is not a
75+
baseline right). Attachment management is therefore enabled by an ordinary,
76+
position-distributed permission set that grants `sys_attachment` CRUD. Until a
77+
member holds such a set, a delete is refused by RBAC (`PERMISSION_DENIED`)
78+
before the attachment-level gate above is even consulted.
79+
</Callout>
80+
81+
## Download — authenticated & parent-scoped
82+
83+
For **`scope: 'attachments'`** files (those created through the Attachments
84+
surface), the download endpoints require a session and read-access to a parent
85+
record, and issue a **short-lived signed URL**:
86+
87+
| Code | Status | When |
88+
| --- | --- | --- |
89+
| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file |
90+
| `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
91+
92+
The gate is scoped to attachments files on purpose: **non-attachments files**
93+
(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous
94+
capability URL, because they are embedded in `<img src>` which cannot carry a
95+
bearer token. Their discovery is already gated by access to the owning record.
96+
97+
The upload entry points (presigned / chunked) likewise require a session when
98+
an auth service is wired, and stamp `owner_id` on the new `sys_file`.
99+
100+
## Storage-byte lifecycle
101+
102+
Deleting attachments does not immediately delete the underlying bytes (a file
103+
can be shared across records). Reclamation is handled by the platform LifecycleService via declarative
104+
[reap guards](/adr/0057-system-data-lifecycle-and-retention):
105+
106+
- **`sys_file`** — when the last `sys_attachment` referencing an
107+
attachments-scope file is deleted, the file is tombstoned; a reap guard
108+
re-verifies zero references at sweep time and deletes the storage bytes
109+
before the row is reaped (abandoned `pending` uploads are reaped too).
110+
- **`sys_upload_session`** — abandoned/terminal chunked-upload sessions are
111+
reaped, and a reap guard aborts the underlying backend multipart upload
112+
(S3 `AbortMultipartUpload` / local parts dir) first, so already-uploaded
113+
parts don't leak.
114+
115+
## Enforcement summary
116+
117+
| Operation | Requirement | Deny code |
118+
| --- | --- | --- |
119+
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
120+
| Attach (create) | can read + edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
121+
| List / read | inherits parent read visibility | *(filtered out)* |
122+
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` / `PERMISSION_DENIED` (403) |
123+
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
124+
125+
## See also
126+
127+
- [`enable.files` object capability](/docs/references/data/object)
128+
- [`services.storage` contract](/docs/kernel/runtime-services/storage-service)
129+
- [Authorization Architecture](/docs/permissions/authorization)
130+
- ADR-0049 (no unenforced security properties), ADR-0057 (data lifecycle), ADR-0066 (object access posture)

content/docs/permissions/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"delegated-administration",
1414
"sharing-rules",
1515
"field-level-security",
16+
"attachments-access",
1617
"permission-metadata",
1718
"permissions-matrix",
1819
"access-matrix",

docs/adr/0057-system-data-lifecycle-and-retention.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ retried next sweep). Rules:
191191
tombstoned by hooks when their last `sys_attachment` reference is deleted,
192192
reaped `30d` later by TTL with byte reclaim, with zero-reference
193193
re-verification at sweep time; abandoned `pending` uploads reap after `7d`.
194+
- Second consumer: `sys_upload_session` (service-storage) — when an
195+
abandoned/terminal chunked-upload session row is reaped, its guard first
196+
aborts the backend multipart upload (S3 `AbortMultipartUpload` / local parts
197+
dir), skipping `completed` sessions and vetoing on abort failure so the
198+
session's already-uploaded parts don't leak.
194199

195200
### 3.4 Reclaim — driver space hygiene
196201

0 commit comments

Comments
 (0)