From 164fa3a933ead15907d4acdd2c2181987f107496 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:34:36 +0000 Subject: [PATCH] docs(permissions): Attachments Access page + ADR-0057 sys_upload_session guard note Documents the attachment access model landed across #2984/#2999/#2538 (#2970): parent-derived read/create/delete, inherited list visibility, authenticated parent-scoped downloads, the enable.files opt-in gate, the member_default no-delete-baseline caveat, the storage-byte lifecycle (sys_file + sys_upload_session reap guards), and an enforcement-summary + error-code table. Registered in permissions/meta.json. Also names sys_upload_session as the second reap-guard consumer in the ADR-0057 amendment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt --- .../docs/permissions/attachments-access.mdx | 130 ++++++++++++++++++ content/docs/permissions/meta.json | 1 + ...057-system-data-lifecycle-and-retention.md | 5 + 3 files changed, 136 insertions(+) create mode 100644 content/docs/permissions/attachments-access.mdx diff --git a/content/docs/permissions/attachments-access.mdx b/content/docs/permissions/attachments-access.mdx new file mode 100644 index 0000000000..c23b635d79 --- /dev/null +++ b/content/docs/permissions/attachments-access.mdx @@ -0,0 +1,130 @@ +--- +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. +--- + +# 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: + +| 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 — read visibility (+ edit-on-parent) & provenance + +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 | + +## 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 `` 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](/adr/0057-system-data-lifecycle-and-retention): + +- **`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). +- **`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 + +| 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) | + +## See also + +- [`enable.files` object capability](/docs/references/data/object) +- [`services.storage` contract](/docs/kernel/runtime-services/storage-service) +- [Authorization Architecture](/docs/permissions/authorization) +- ADR-0049 (no unenforced security properties), ADR-0057 (data lifecycle), ADR-0066 (object access posture) diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json index 852a9dde7d..4da7101f22 100644 --- a/content/docs/permissions/meta.json +++ b/content/docs/permissions/meta.json @@ -13,6 +13,7 @@ "delegated-administration", "sharing-rules", "field-level-security", + "attachments-access", "permission-metadata", "permissions-matrix", "access-matrix", diff --git a/docs/adr/0057-system-data-lifecycle-and-retention.md b/docs/adr/0057-system-data-lifecycle-and-retention.md index b4b587d735..848995804a 100644 --- a/docs/adr/0057-system-data-lifecycle-and-retention.md +++ b/docs/adr/0057-system-data-lifecycle-and-retention.md @@ -191,6 +191,11 @@ retried next sweep). Rules: tombstoned by hooks when their last `sys_attachment` reference is deleted, reaped `30d` later by TTL with byte reclaim, with zero-reference re-verification at sweep time; abandoned `pending` uploads reap after `7d`. +- Second consumer: `sys_upload_session` (service-storage) — when an + abandoned/terminal chunked-upload session row is reaped, its guard first + aborts the backend multipart upload (S3 `AbortMultipartUpload` / local parts + dir), skipping `completed` sessions and vetoing on abort failure so the + session's already-uploaded parts don't leak. ### 3.4 Reclaim — driver space hygiene