Attachments Access
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.
Attachments Access
The generic Attachments surface (Salesforce "Notes & Attachments" parity) separates file storage from where a file is attached:
sys_file— one row per uploaded blob (the bytes live in the storage backend; the row holdskey,scope,owner_id,status).sys_attachment— a polymorphic join row linking asys_fileto any record viaparent_object+parent_id(like SalesforceContentDocumentLink). One file can be attached to many records.
The governing principle: an attachment has no access model of its own — it inherits its parent record's. A caller who can read a record can read its attachments; a caller who can edit a record can attach to and detach from it. Enforcement is layered, and every gate is fail-closed.
Field.file / Field.image are a separate path — those store a file URL
in the record's own column and never create a sys_attachment row, so nothing
on this page applies to them.
The opt-in gate — enable.files
Attachments are opt-in per object (spec default false). A sys_attachment
row may only target an object that declares enable: { files: true }; the
Attachments panel renders only for such objects. Any other target is rejected:
| Code | Status | When |
|---|---|---|
FILES_DISABLED | 403 | Creating a sys_attachment whose parent_object does not declare enable.files: true |
This is enforced by a beforeInsert hook on sys_attachment and is
independent of the record-level checks below.
Create — parent-edit access & provenance
Creating a sys_attachment requires that the caller can edit the parent
record — Salesforce edit-on-parent parity — verified with the sharing service's
canEdit, so RLS / OWD / sharing of the parent object apply (public-model
parents are editable by any member by design). When no sharing service is
wired, the gate degrades to caller-scoped read visibility (a findOne).
uploaded_by is server-stamped from the session; a client-supplied value
is ignored.
| Code | Status | When |
|---|---|---|
ATTACHMENT_PARENT_ACCESS | 403 | The caller cannot access the parent record they are attaching to |
Read / list — inherited parent visibility
Listing or reading sys_attachment only returns rows whose parent record the
caller can read. This is enforced by a sys_attachment-scoped engine
middleware (not a hook), so it filters find, findOne, count, and
aggregate identically — the list total cannot leak the count of hidden
rows. Per distinct parent_object, the visible parent ids are resolved through
the caller-scoped engine (the parent object's own RLS applies) and folded into
the query; a caller with no visible parent gets an empty result. Failure to
resolve the filter fails closed (deny-all).
Delete — uploader or parent editor
Deleting a sys_attachment is allowed when the caller is the uploader OR
can edit the parent record (sharing.canEdit — public-model parents are
editable by design). A multi-delete requires every matched row to pass.
| Code | Status | When |
|---|---|---|
ATTACHMENT_DELETE_DENIED | 403 | The caller is neither the uploader nor able to edit the parent record |
The platform baseline permission set (member_default, the everyone
anchor) grants no delete on sys_attachment (ADR-0090 D5 — delete is not a
baseline right). Attachment management is therefore enabled by an ordinary,
position-distributed permission set that grants sys_attachment CRUD. Until a
member holds such a set, a delete is refused by RBAC (PERMISSION_DENIED)
before the attachment-level gate above is even consulted.
Download — authenticated & parent-scoped
For scope: 'attachments' files (those created through the Attachments
surface), the download endpoints require a session and read-access to a parent
record, and issue a short-lived signed URL:
| Code | Status | When |
|---|---|---|
AUTH_REQUIRED | 401 | Anonymous download of an attachments-scope file |
ATTACHMENT_DOWNLOAD_DENIED | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
The gate is scoped to attachments files on purpose: non-attachments files
(avatars, Field.image thumbnails, org logos) keep their stable, anonymous
capability URL, because they are embedded in <img src> which cannot carry a
bearer token. Their discovery is already gated by access to the owning record.
The upload entry points (presigned / chunked) likewise require a session when
an auth service is wired, and stamp owner_id on the new sys_file.
Storage-byte lifecycle
Deleting attachments does not immediately delete the underlying bytes (a file can be shared across records). Reclamation is handled by the platform LifecycleService via declarative reap guards:
sys_file— when the lastsys_attachmentreferencing an attachments-scope file is deleted, the file is tombstoned; a reap guard re-verifies zero references at sweep time and deletes the storage bytes before the row is reaped (abandonedpendinguploads are reaped too). A tombstone is recoverable state, not a delete — and every reader treats it that way. Re-attaching the file, or re-claiming it through a record field, makes it readable again immediately, with no sweep in between: the download endpoints and record file-field hydration ask the same "is anything still holding this file?" question the reap guard asks before it reclaims anything. Both reach it through that one predicate rather than each deciding for itself, which is what stops the two surfaces answering differently about one row — a fileGET /storage/files/:idserves is a file a record read expands into{ id, name, size, mimeType, url }. The row itself stays tombstoned until a sweep tidies it, and a file with no holder left still answersFILE_NOT_FOUND(404) and keeps its bare id in a record payload.sys_upload_session— abandoned/terminal chunked-upload sessions are reaped, and a reap guard aborts the underlying backend multipart upload (S3AbortMultipartUpload/ local parts dir) first, so already-uploaded parts don't leak.
Enforcement summary
| Operation | Requirement | Deny code |
|---|---|---|
| Attach (create) | parent object opts in (enable.files) | FILES_DISABLED (403) |
| Attach (create) | can edit the parent record | ATTACHMENT_PARENT_ACCESS (403) |
| List / read | inherits parent read visibility | (filtered out) |
| Delete | uploader or parent editor (+ RBAC delete grant) | ATTACHMENT_DELETE_DENIED / PERMISSION_DENIED (403) |
| Download | session + owner-or-parent-read (attachments scope) | AUTH_REQUIRED (401) / ATTACHMENT_DOWNLOAD_DENIED (403) |
See also
enable.filesobject capabilityservices.storagecontract- Authorization Architecture
- ADR-0049 (no unenforced security properties), ADR-0057 (data lifecycle), ADR-0066 (object access posture)