|
| 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) |
0 commit comments