Skip to content

feat(attachments): v2 follow-ups — read visibility, authed downloads, edit-on-parent, lifecycle, enforce-or-remove (#2970) - #2984

Merged
os-zhuang merged 3 commits into
mainfrom
claude/attachments-v1-followups-8jhvin
Jul 16, 2026
Merged

feat(attachments): v2 follow-ups — read visibility, authed downloads, edit-on-parent, lifecycle, enforce-or-remove (#2970)#2984
os-zhuang merged 3 commits into
mainfrom
claude/attachments-v1-followups-8jhvin

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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_attachment reads (find/list/count/aggregate) now inherit the parent record's visibility, via a sys_attachment-scoped engine middleware (not a find-hook — so list total, which comes from engine.count(), is filtered identically and can't leak the hidden-row count). Generalizes ADR-0055 controlled_by_parent to the polymorphic parent: resolves visible parent ids per parent_object through the caller-scoped engine, ANDs a $or of {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. For scope==='attachments', non-public_read files they now gate on authorizeFileRead: 401 AUTH_REQUIRED without a session, 403 ATTACHMENT_DOWNLOAD_DENIED when the caller is neither the owner nor able to read a parent record (full caller context via resolveAuthzContext), 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_attachment now requires EDIT on the parent (sharing canEdit), not merely read — public parents unchanged, private/owner-scoped require own/edit.

4. sys_upload_session lifecycle

An ADR-0057 transient lifecycle (TTL 1d past expires_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.trashfalse (ADR-0049)

The flag is dead in 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_project opts into enable.files so the Attachments panel renders for browser dogfooding.

Verification

  • Unit: @objectstack/service-storage 93 ✓ (read-visibility middleware, download gate, edit-on-parent, access hooks), platform-objects 197 ✓, objectql/rest unchanged-green.
  • Dogfood: the attachments matrix is now 15 cases (read-visibility denial, download gate anon/cross-user/owner/parent-inherited, edit-on-parent read-but-not-edit, session reap); full suite 256 ✓ / 3 skipped.
  • Real-server E2E (booted objectstack dev showcase): 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.
  • Browser (objectui HMR console + booted server, both with these changes): the Attachments panel renders on a showcase_project detail page, lists the file, and the Download button fetches the authenticated /storage/files/:id/url signed-URL endpoint. Screenshot in the session.
  • Gates: check:liveness ✓, check:api-surface ✓.

ℹ️ The red Validate Dependencies check is the repo-wide retired-npm pnpm audit endpoint (410), unrelated to this diff.

Companion objectui PR: objectstack-ai/objectui#2538 (panel download via signed URL). Closes#2970.

🤖 Generated with Claude Code

…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
@vercel

vercelBot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 15, 2026 5:09pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 15, 2026
@github-actions

github-actionsBot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/dogfood, @objectstack/platform-objects, packages/services.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via packages/services)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/permissions/authorization.mdx(via packages/dogfood)
  • content/docs/permissions/delegated-administration.mdx(via packages/dogfood)
  • content/docs/plugins/packages.mdx(via @objectstack/platform-objects, packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/services)
  • content/docs/ui/setup-app.mdx(via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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-zhuangos-zhuang changed the title feat(attachments): sys_attachment read inherits parent-record visibility (#2970)feat(attachments): v2 follow-ups — read visibility, authed downloads, edit-on-parent, lifecycle, enforce-or-remove (#2970)Jul 15, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 16, 2026 01:09
@os-zhuang
os-zhuang merged commit e1484bc into mainJul 16, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/attachments-v1-followups-8jhvin branch July 16, 2026 01:09
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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[attachments] v2 tracking: parent-visibility inheritance, authed downloads, remaining enforce-or-remove

2 participants

@os-zhuang@claude