Skip to content

Access control fails OPEN past include depth 5: caller includes pass through unscoped and nested rows skip field-read filtering #830

Description

@borisno2

Summary

Both halves of the read-side access-control pipeline stop at MAX_DEPTH = 5, and both fail open rather than closed. Past that depth a caller-supplied include is neither row-scoped nor field-filtered, so a relation returns every row of the related list with read-denied columns intact.

The two cut-offs are independent but line up, so they compound:

1. Row scoping — packages/core/src/access/access-filter.ts

buildIncludeWithAccessControl returns undefined at depth >= MAX_DEPTH. mergeIncludeWithAccessControl treats whole-object undefined as "nothing to merge against" and returns the caller's include unchanged. The behaviour is deliberate and documented in the source:

The access-controlled include is recursive to MAX_DEPTH (see buildIncludeWithAccessControl); beyond that depth no auto-include exists, so deeper caller selections pass through unscoped — consistent with the existing auto-include behaviour.

The distinction the code draws — a whole-object undefined is a passthrough, an undefined entry is a denial — is reasonable for the resolveOutput/virtual-field and no-relationships cases it also covers. The MAX_DEPTH case is the one where passthrough is the wrong default, because it is the only one where a relation genuinely should have been scoped and wasn't.

2. Field visibility — packages/core/src/access/field-visibility.ts

filterReadableFields recurses only while depth < MAX_DEPTH. Past that, a relationship value falls through to resolveReadableFieldValue, which checks the read gate on the relationship field itself and then returns the nested object as-is. The related row's own per-field access.read gates are never evaluated, and its resolveOutput hooks never run.

Why this matters

The failure is silent and invisible. There is no warning, no thrown error, and nothing in the returned shape that distinguishes "this relation was access-filtered" from "this relation was returned raw because it sat one level too deep". A reviewer reading the call site sees an ordinary include and reasonably assumes the engine scoped it — the framework's central promise.

It is also discontinuous: the same relation on the same list is filtered at depth 4 and unfiltered at depth 5. Adding one intermediate hop to an existing include — a refactor that looks purely additive — can silently switch off access control on the leaf. Nothing in the type system or at runtime flags it.

The consequences depend on what sits at the leaf:

  • Row scoping lost → a relation that should be scoped to the caller's own tenant/owner returns rows belonging to everyone.
  • Field filtering lost → columns gated with access: { read: … } (tokens, secrets, internal-only fields) are returned to callers who should not see them.
  • resolveOutput skipped → field types that normalise on read (the date-only normaliser, wrapper types) return their raw database representation instead, so a deeply-nested value silently changes type.

Reproduction

  1. Define lists A → B → C → D → E → F, each related to the next.
  2. Put a row-scoping filter on F's query access, and a read-denied field on F.
  3. As a non-privileged session, read A with a caller-supplied include chain down to F.
  4. F comes back with rows outside the filter and with the read-denied field populated.

The same chain terminating at depth 4 behaves correctly.

Suggested direction

Fail closed at the boundary. Concretely, one of:

  • Drop the relation past MAX_DEPTH rather than passing it through — treat "I could not compute an access include for this" as a denial, distinct from the resolveOutput/no-relationships cases that legitimately mean "nothing to scope".
  • Or throw on a caller include that exceeds the depth the engine can scope, so the limit surfaces at development time instead of becoming a quiet data leak in production.

Either way, filterReadableFields' cut-off should move in step with buildIncludeWithAccessControl's — right now a row can be scoped correctly and still return unfiltered fields, or vice versa, because the two limits are enforced independently.

If the depth cap must stay a passthrough for backwards compatibility, it would help to at least make it observable: a logged warning, or a flag on the result, so applications can detect that a read escaped the engine rather than discovering it by audit.

Related consideration: MAX_DEPTH = 5 is also duplicated in context/nested-operations.ts and context/transaction-boundary.ts. If the cap stays, a single shared constant with a comment on the security implication would make the coupling explicit.

Environment

  • @opensaas/stack-core 0.32.0 (verified against main at time of writing — the code and the documenting comments are both present in the current source)

Metadata

Metadata

Assignees

Labels

agent-handoffbugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions