Skip to content

canAccessFile() checks only the first ~10 referencing records — false permission denials #108

Description

@cuibonobo

Problem

ScopedStack.canAccessFile() (packages/core/src/stack.ts:1829) decides file access partly by "can the requester read any record that references this fileId." It does so with:

const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) return true;

this.query is ScopedStack.query, which filters-then-refills but caps at maxFetched = limit * 10 and returns a cursor rather than exhausting (stack.ts:1988-2010). With limit: 1 that cap is 10 underlying records. So only the first ~10 records referencing the file (in default sort order) are ever canRead-checked. If those ten are unreadable by the requester but an eleventh referencing record is readable, canAccessFile wrongly returns false.

Real-world trigger: a widely-referenced file — a group header image, a shared logo, an avatar reused across many records — attached to many records the requester can't read plus one they can. Result: a false StackPermissionError on getAttachment() (stack.ts:2156) and on legitimate reference creation via the #51 gates (requireAssociationAccess/requireFileRefAccess). This is the "treating one query() call as exhaustive is a caller bug" rule from #50 — violated inside the permission predicate itself.

Fix

Scan referencing records to exhaustion but short-circuit on the first readable one, rather than relying on ScopedStack.query's bounded refill. Iterate pages of the unscoped stack.query({ filter: { attachmentFileId: fileId } }) and canRead() each (reusing a one-time grant prefetch, as ScopedStack.query already does, so per-record checks stay cheap), returning true on the first pass and false only after genuine exhaustion. Bound the walk with the existing QUERY_ALL_MAX safety cap so a pathologically-referenced file surfaces a StackQueryError rather than scanning unboundedly. Keep the current includeDeleted: false scope unless we deliberately decide soft-deleted referents should convey download access (out of scope here — note only).

The same bounded-scan-treated-as-exhaustive bug class appears in ensureOwnerEntity() (assessment §E2, duplicate owner cards past ~50 _entity records); consider a shared "first record matching predicate, walked to exhaustion" helper and fixing both together.

Tests

  • A file referenced by >10 records where only one past position 10 is readable by the requester: getAttachment() succeeds (regression)
  • Reference creation (associate an attachment / write a file-ref field) succeeds under the same >10-referent shape
  • A requester who can read none of the referencing records still gets StackPermissionError (no false positive introduced)
  • Anti-oracle preserved: missing file and inaccessible file remain indistinguishable

Refs

#50 (exhaustive-pagination-is-the-caller's-job), #51 (the gates that consume canAccessFile). From docs/design-assessment-2026-07.md §A3 (and §E2 for the sibling case) (PR #105).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions