Attachment access — who can read a file
Who can read, upload or delete a record's attachments: the parent-derived access model, authenticated downloads, the enable.files gate and file cleanup.
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 an opaque
sys_file id in the record's own column (ADR-0104 D3; the
{ id, name, size, mimeType, url } shape is derived at read time, never
stored) 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 (multi: true) matches only attachments
the caller can read: an attachment whose parent is out of the caller's sight is
not matched, not removed and not counted, so a predicate that reaches only such
attachments answers exactly what a predicate that matches nothing answers. Every
matched row must then pass.
| Code | Status | When |
|---|---|---|
ATTACHMENT_DELETE_DENIED | 403 | The caller can read the attachment, but is neither the uploader nor able to edit the parent record |
RECORD_NOT_FOUND | 404 | A delete or update by id of an attachment the caller cannot read — its parent record is not readable to them. On the write doors a row the caller cannot read is a row that does not exist: the answer is exactly what an id that names no attachment gets, for every caller, so a hidden attachment and a missing one cannot be told apart. It applies even to the uploader once the parent is out of their sight, because the read door no longer returns the attachment to them either |
PERMISSION_DENIED | 403 | The gate's own not-visible refusal, where the gate answers before that check. It names neither the parent nor the attachment's link to it |
An update of another user's attachment follows the same rule — the uploader or a parent editor — and a by-id update of an attachment the caller cannot read gets the same not-found answer. A multi-update matches only attachments the caller can read, as a multi-delete does.
The platform baseline also ships a parent-blind row-level delete floor
(owner_only_deletes: you may delete only the rows you created, for members
holding org_member). It would refuse a parent editor before this gate is
asked, so the storage service contributes a delete-only alternate match beside
the floor when it installs the gate, and the gate decides. The alternate match
exists only where the gate does: a deployment that registers sys_attachment
without @objectstack/service-storage keeps the floor, and there only the row's
creator may delete it. The floor's edit limb is not relieved: a member the
floor binds still cannot edit another user's attachment row
(PERMISSION_DENIED), even as a parent editor.
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 any file not marked acl: 'public_read' (see below), or a credential the organization wall refuses |
ATTACHMENT_DOWNLOAD_DENIED | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
The 401 is the generic "unauthenticated" answer, and it has always covered more than a missing credential — an unknown, revoked or expired API key reads the same way. Under a wall-enforcing tenancy posture two further credentials join it, refused before this door's ownership and parent-read checks are consulted:
- an API key stamped with an organization its owner has left — refused
under both
groupandisolated, the two postures that enforce a wall; - an API key carrying no organization at all — refused under
isolatedonly.groupreads across the caller's whole membership set, so a key with no organization is still admitted there.
Under single neither applies — there is no organization wall for a key to be
walled out of. Both refusals deliberately tell the caller nothing more than the
anonymous case does: the response is byte-identical to sending no credential at
all, and the reason is written to the server log instead. The check itself lives
in the shared API-key admission path, not in the attachments gate.
The parent-record check applies to files that have a parent: attachments-scope
files, and files a record's file / image field owns, which are judged against
that one record (FILE_DOWNLOAD_DENIED, 403, when it cannot be read). A file with
neither — an upload no record has claimed, such as an avatar or an
organization logo stored as a URL — has no parent to check, so its download
requires only a signed-in caller (AUTH_REQUIRED, 401, otherwise). A browser's
<img src> sends the session cookie set at sign-in, so a signed-in page keeps
rendering these files. Only a file marked acl: 'public_read' is served to a
caller with no session: mark a file that way when it must render before sign-in.
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 (403); PERMISSION_DENIED (403) when the parent is not readable or no delete grant is held |
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for acl: 'public_read' | 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)