feat(attachments): v2 follow-ups — read visibility, authed downloads, edit-on-parent, lifecycle, enforce-or-remove (#2970)#2984
Merged
Conversation
…ity (#2970) Follow-up to #2755. The create/delete gates landed, but a member could still LIST sys_attachment rows (file_name, size, parent_id) pointing at records they cannot read — an info leak, since attachment access derives from the parent record. sys_attachment is public with no owner field, so the sharing/RLS static predicates never narrowed it. installAttachmentReadVisibility registers a sys_attachment-scoped engine MIDDLEWARE (not a find-hook): middleware runs for find/findOne/count/ aggregate, so the list total (from engine.count(), never the find path) is filtered identically and cannot leak the hidden-row count — a before/afterFind hook would leave count() unfiltered. Generalizing ADR-0055 controlled_by_parent to the polymorphic parent, each read resolves the visible parent ids per parent_object through the caller-scoped engine (the parent's own RLS/OWD/sharing apply) and ANDs a $or of { parent_object, parent_id: { $in } } into the query; no visible parent → deny-all sentinel; fails closed on compute error; the candidate pre-scan cap logs (not silent) when it truncates. System/context-less internal reads are not narrowed. Dogfood: the (c) matrix pin flips from leak-asserted to a denial — a member cannot list/read/by-id-fetch attachments of an invisible parent, total does not leak the count, and the control proves they still see attachments on records they CAN read. 19 unit tests; full dogfood 256 ✓. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
7 tasks
The storage download endpoints (GET /storage/files/:fileId and /files/:fileId/url) were anonymous capability URLs — anyone holding a fileId could mint a download with no session or access check. For scope='attachments', non-public_read files, both endpoints now gate on a new authorizeFileRead seam: 401 AUTH_REQUIRED without a session, 403 ATTACHMENT_DOWNLOAD_DENIED when the caller is neither the file owner nor able to READ a record the file is attached to (parent-derived, resolved through the full caller context via resolveAuthzContext), else a short-lived signed URL (downloadTtl, default 300s). Non-attachments files (field files, avatars, org logos — embedded in <img src> which can't carry a bearer) keep the stable anonymous capability URL; bare kernels/tests without the seam stay open (back-compat). Tests: storage-routes gate unit tests (401/403/allow/public/non-attach/ open); the dogfood matrix (e-read) pin flips from a leak to a denial — anon 401, cross-user 403, owner 200, parent-inherited 200, 302 anon 401. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
…lse, showcase files (#2970 items 3-6) - item 3: attach requires EDIT on the parent record (sharing.canEdit), not merely read (Salesforce parity); public parents unchanged, private owner-scoped parents require own/edit. Read fallback without sharing. - item 4: sys_upload_session gains an ADR-0057 transient lifecycle (TTL 1d past expires_at + 7d retention for terminal statuses) so abandoned chunked-upload sessions are reaped. Row reap only; multipart-abort guard is a follow-up. - item 5: sys_attachment enable.trash -> false. The flag is dead in the liveness ledger and deletes are hard (reap guard reclaims bytes), so a restore would dangle; declare the honest state (ADR-0049). - item 6: showcase_project opts into enable.files so the attachments panel renders for browser dogfooding. - dogfood: att_readonly (public_read) fixture + matrix cases for edit-on-parent (read-but-not-edit → 403, still listable) and sys_upload_session reap; access-hook unit tests for the canEdit path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
os-zhuang
marked this pull request as ready for review
July 16, 2026 01:09
This was referenced Jul 16, 2026
Merged
os-zhuang
added a commit
that referenced
this pull request
Jul 16, 2026
…ion guard note (#3009) 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. Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Jul 17, 2026
…ad_session (objectstack-ai#2970) The sys_upload_session lifecycle (objectstack-ai#2984) reaps abandoned/terminal chunked-upload session rows but not the underlying backend multipart — on S3 an initiated-but-not-completed multipart keeps its parts billable and invisible until AbortMultipartUpload, so reaping only the row stranded them (backend_upload_id, the sole pointer, gone). createUploadSessionReapGuard registers a LifecycleReapGuard on sys_upload_session that aborts the backend multipart before the row is deleted: skips 'completed' sessions (multipart already an object — an abort would NoSuchUpload-error), re-seeds the S3 uploadId->key map from the row (cold sweeps lack the live in-process map), and vetoes (keeps the row for retry) on abort failure so the pointer survives. Local adapter parts dir removed the same way. Verified: 7 new unit tests (S3 key re-seed, completed-skip, no-backend, veto-on-failure, local adapter, no-abort adapter); service-storage 99; dogfood matrix drives a real chunked upload → part on disk → sweep → row gone AND parts dir aborted. Closes the last objectstack-ai#2970 sub-follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes all of the #2970 tracking issue (follow-up to #2755 / merged #2969). Six items, verified by unit + dogfood + a real-browser pass.
1. Read visibility inheritance
sys_attachmentreads (find/list/count/aggregate) now inherit the parent record's visibility, via asys_attachment-scoped engine middleware (not a find-hook — so listtotal, which comes fromengine.count(), is filtered identically and can't leak the hidden-row count). Generalizes ADR-0055controlled_by_parentto the polymorphic parent: resolves visible parent ids perparent_objectthrough the caller-scoped engine, ANDs a$orof{parent_object, parent_id:{$in}}; deny-all when none; fail-closed on error.2. Authenticated, parent-scoped downloads
GET /storage/files/:fileId(/url)were anonymous capability URLs. Forscope==='attachments', non-public_readfiles they now gate onauthorizeFileRead:401 AUTH_REQUIREDwithout a session,403 ATTACHMENT_DOWNLOAD_DENIEDwhen the caller is neither the owner nor able to read a parent record (full caller context viaresolveAuthzContext), else a short-lived (downloadTtl, 300s) signed URL. Non-attachments files (avatars, field images embedded in<img src>) keep the stable anonymous URL.3. Edit-on-parent for attach (Salesforce parity)
Creating a
sys_attachmentnow requires EDIT on the parent (sharingcanEdit), not merely read — public parents unchanged, private/owner-scoped require own/edit.4.
sys_upload_sessionlifecycleAn ADR-0057
transientlifecycle (TTL 1d pastexpires_at; retention 7d for terminal statuses) reaps abandoned/terminal chunked-upload sessions. Row reap only; a backend multipart-abort guard is a filed follow-up.5.
sys_attachment.enable.trash→false(ADR-0049)The flag is
deadin the liveness ledger and deletes are hard (the reap guard reclaims bytes), so a restore would dangle — declare the honest state.6. Showcase files-enabled object
showcase_projectopts intoenable.filesso the Attachments panel renders for browser dogfooding.Verification
@objectstack/service-storage93 ✓ (read-visibility middleware, download gate, edit-on-parent, access hooks),platform-objects197 ✓,objectql/restunchanged-green.objectstack devshowcase): 15/15 — admin create/upload/attach/list, FILES_DISABLED, anon download 401, admin signed URL 200, signed URL serves the bytes, member RBAC-denied attach (grant-needed finding), member parent-inherited download 200.showcase_projectdetail page, lists the file, and the Download button fetches the authenticated/storage/files/:id/urlsigned-URL endpoint. Screenshot in the session.check:liveness✓,check:api-surface✓.Companion objectui PR: objectstack-ai/objectui#2538 (panel download via signed URL). Closes #2970.
🤖 Generated with Claude Code