| title | Attachments Access |
|---|---|
| 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. |
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.
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.
Creating a sys_attachment requires that the caller can read the parent
record (verified with a caller-scoped findOne, so RLS / OWD / sharing of the
parent object apply), and — for edit-on-parent parity — that they can edit it.
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 |
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).
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 |
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.
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).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.
| Operation | Requirement | Deny code |
|---|---|---|
| Attach (create) | parent object opts in (enable.files) |
FILES_DISABLED (403) |
| Attach (create) | can read + 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) |
enable.filesobject capabilityservices.storagecontract- Authorization Architecture
- ADR-0049 (no unenforced security properties), ADR-0057 (data lifecycle), ADR-0066 (object access posture)