Skip to content

v27.0.0

Choose a tag to compare

@github-actions github-actions released this 09 Jun 13:21
· 1 commit to main since this release
0747891

27.0.0 (2026-06-09)

⚠ BREAKING CHANGES

  • permissions: the singular entity(where: …) query arg is now
    ${name}WhereLookup! (was ${name}WhereUnique! in v24.0.0).
    Consumers' GraphQL queries don't need to change shape — the cascade
    fields they had to add at v24.0.0 are still required — but generated
    TypeScript types for query variables now reference ${name}WhereLookup
    instead of ${name}WhereUnique. Mutations are unaffected by this
    commit; ${name}WhereUnique is back to v23 behavior and existing
    mutation strings (where: { id: $id }) work again.

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

  • feat(permissions): skip joined-entity WHERE on filter joins covered by root scope

When a query's WHERE traverses a relation only through field paths that
are already constrained by the queried entity's own permission WHERE,
the joined entity's Entity.WHERE adds nothing: every row it would
exclude has already been excluded by the root scope, and every row that
survives the root scope is one the user could have learned about by
reading the root entity anyway. In that case we skip the joined-entity
permission check on the filter join.

Concretely, with

Portfolio: { WHERE: { goal: { status:[ACTIVE], relation:{ status:[ACTIVE] } } } }
Goal: { WHERE: { demo: true } }
Relation: { WHERE: { demo: true } }

portfolios(where: { goal: { status:[ACTIVE], relation:{ status:[ACTIVE] } } })
no longer AND-s in goal.demo:true / relation.demo:true on the filter
join — the user's leaves are exactly the leaves the root scope already
pins, so the result is the full intersection the role is entitled to
without further demo-gating. A portfolios(where: { goal: { demo: false } }),
by contrast, falls back to strict per-table permission application
because the goal.demo leaf is not in the root's covered set.

The check is deliberately narrow:

  • It fires only for filter-joined aliases (…__W__<rel> / …__WS__<rel>).
    Direct top-level <entity>(where: …) queries hit the root alias and
    apply Entity.WHERE exactly as before.
  • It fires only when every leaf the user supplied at or below the join
    is in the root permission's covered leaf-path set. A single
    uncovered leaf at any depth re-enables the strict path.
  • Coverage is derived solely from each chain's root link's where; no
    per-field flags, no field-metadata coupling — adding a queriable
    annotation can never accidentally widen filterability.
  • Field-selection sub-queries (portfolio.goal { name }) go through
    applySubQueries, which re-runs applyPermissions on the joined
    entity for the field read. Filtering at the join skips Goal.WHERE;
    reading goal fields on the result still gates on Goal.READ.

Adds:

  • collectLeafPaths(where) + collectCoveredLeafPaths(stack) in
    permissions/check.ts — walks AND / OR / NOT, treats arrays /
    primitives as leaves and plain objects as relation traversals,
    collapses _<OP> filter-operator suffixes (_IN, _GT, _SOME,
    …) so the path comparison is on the field, not the operator.
  • collectUserLeavesByAlias(model, where) in resolvers/filters.ts
    — walks the user's WHERE with the same alias scheme as
    applyWhere (${model.name}__W__${key}) and reports the leaves
    each filter-join alias carries at or below.
  • resolver loop in buildQuery consults both before calling
    applyPermissions on each joined table; an unbounded fallback
    preserves the existing strict behaviour whenever any precondition
    fails.

Tests in tests/unit/permission-filter-coverage.spec.ts cover the
collectors (AND / OR / NOT, _<OP> collapse, empty input) and the
decision matrix (fully-covered → skip, mixed → no skip, deeper-only
covered path → skip both joins).

  • Revert "feat(permissions): skip joined-entity WHERE on filter joins covered by root scope"

This reverts commit fcf0273.

  • feat(permissions): skip joined-table READ on schema-mandated filter joins

filterable: { nonNull: true } is a schema-level contract that every
client MUST filter by the field. Requiring read permission on the
joined entity at runtime would make the contract unsatisfiable for any
role that lacks that permission — they cannot opt out at the schema
layer and cannot supply the field at runtime either. So on filter-join
aliases produced by a fully-mandatory cascade, applyPermissions for
the joined table is skipped.

The rule (collectMandatoryFilterAliases in resolvers/filters.ts):

  • The relation field carrying the join is filterable: { nonNull: true },
  • the user's WHERE contents at that position are minimal (every leaf
    is itself a filterable: { nonNull: true } field — relations are
    recursively checked the same way),
  • and the whole chain from the root entity to this alias is built
    from nonNull-filterable relations (no opt-in filterable: true
    relation upstream).

Any filterable: true field, _<OP> / _SOME / _NONE suffix, or
optional traversal upstream drops the alias to the strict per-table
permission stack — those are the client's free choice and are
permission-gated as before. Top-level <entity>(where: …) queries are
unaffected: they hit applyPermissions on the root alias, never on a
__W__ / __WS__ alias.

Replaces the earlier "covered leaf paths" rule (reverted in the
preceding commit), which keyed off the permission WHERE and could
relax the joined check based on a permission addition — semantically
the wrong direction. The new rule keys off the schema and bypasses
the joined check only where the schema mandates the filter.

Tests in tests/unit/mandatory-filter-aliases.spec.ts cover the
positive case (full mandatory cascade), the inner-only minimal case,
optional-relation fallback (filter: true), opt-in suffixes
(_IN / _SOME), and AND/OR/NOT distribution.

  • fix(lint): satisfy curly and prettier rules in collectMandatoryFilterAliases

Features

  • permissions: allow filtering by mandatory filters regardless of read permissions (#485) (0747891)