ObjectStackObjectStack

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 holds key, scope, owner_id, status).
  • sys_attachment — a polymorphic join row linking a sys_file to any record via parent_object + parent_id (like Salesforce ContentDocumentLink). 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:

CodeStatusWhen
FILES_DISABLED403Creating 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.

CodeStatusWhen
ATTACHMENT_PARENT_ACCESS403The 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.

CodeStatusWhen
ATTACHMENT_DELETE_DENIED403The 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:

CodeStatusWhen
AUTH_REQUIRED401Anonymous download of an attachments-scope file
ATTACHMENT_DOWNLOAD_DENIED403The 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 last sys_attachment referencing 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 (abandoned pending uploads 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 file GET /storage/files/:id serves 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 answers FILE_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 (S3 AbortMultipartUpload / local parts dir) first, so already-uploaded parts don't leak.

Enforcement summary

OperationRequirementDeny code
Attach (create)parent object opts in (enable.files)FILES_DISABLED (403)
Attach (create)can edit the parent recordATTACHMENT_PARENT_ACCESS (403)
List / readinherits parent read visibility(filtered out)
Deleteuploader or parent editor (+ RBAC delete grant)ATTACHMENT_DELETE_DENIED / PERMISSION_DENIED (403)
Downloadsession + owner-or-parent-read (attachments scope)AUTH_REQUIRED (401) / ATTACHMENT_DOWNLOAD_DENIED (403)

See also

On this page