diff --git a/.changeset/quiet-owls-lower.md b/.changeset/quiet-owls-lower.md new file mode 100644 index 000000000..88c808332 --- /dev/null +++ b/.changeset/quiet-owls-lower.md @@ -0,0 +1,85 @@ +--- +'@opensaas/stack-core': minor +'@opensaas/stack-ui': minor +'@opensaas/stack-cli': patch +--- + +The secured surface takes the closed Where vocabulary, lowered in one place + +`context.db..where(...)` now accepts the whole vocabulary — `equals`, +`not`, `in`, `notIn`, `lt`, `lte`, `gt`, `gte`, `contains`, the `AND`/`OR`/`NOT` +combinators, and `some`/`every`/`none` on a relation of any cardinality — plus a +new scalar-only `.orderBy()`. Everything lowers onto the ORM's predicate lambda +in one place (ADR-0055). + +```typescript +const posts = await context.db.Post.where({ + OR: [{ title: { contains: 'release' } }, { views: { gte: 100 } }], + author: { some: { handle: { equals: 'ada' } } }, +}) + .orderBy({ views: 'desc' }) + .all() +``` + +- `contains` is engine-escaped and case-insensitive, so `contains: '50%'` matches + a literal per-cent sign rather than binding a wildcard. +- `equals: null` lowers to `IS NULL`, `not: null` to `IS NOT NULL`. +- A relation predicate scopes the `EXISTS` by the related list's own `query` + access. `some` and `none` ask about the rows the caller may see; `every` asks + whether every row the caller may see matches, so a row the caller cannot see + never decides the parent's membership. A related list the session cannot + query is the empty set: `some` is false, `none` and `every` are true. +- An Access Filter that scopes by a relation is expanded into the related list's + own Access Filter. A filter that expands into itself — directly, or through + another list — throws `AccessFilterRecursionError` naming the chain, rather + than recursing until the process runs out of memory. An acyclic chain deeper + than ten lists is refused the same way. Failing closed is deliberate: a + truncated Access Filter is a widened read. +- An unknown key or operator is a `ValidationError` naming the list and the key, + under `sudo` too. A key the session cannot read is refused with the identical + message a key the list does not declare gets, so the refusal is not an + existence oracle; a denied caller still gets the Silent failure first and sees + no validation error at all. + +**Lowering is now total.** A condition that resolved to `undefined` is refused +rather than dropped, on both spellings. An access rule written as +`({ session }) => ({ authorId: session?.userId })` used to match every row for an +anonymous caller; it now throws. Spell the denial: + +```typescript +// Before — silently matched everything when session was null +query: ({ session }) => ({ authorId: session?.userId }) + +// After +query: ({ session }) => (session ? { authorId: { equals: session.userId } } : false) +``` + +The same refusal now covers the clause `mergeFilters` folds in, so the guarantee +holds on every surface rather than only on `.where().all()/.first()`: an access +filter carrying an `undefined` condition anywhere (including nested under an +operator or inside an `AND`/`OR` branch) throws the new, exported +`UndefinedAccessFilterError`. A caller's own `where` is untouched — this applies +only to what an access rule returns. + +**Also changed:** the filter engine's `FilterCondition` is a Where vocabulary +value; a to-one relationship's label filter emits `some` rather than `is`; a +to-many count filter shrinks to presence (`orders:0` → `none`, `orders:>0` / +`orders:>=1` → `some`, any other comparison degrades to free text); +and read-path key validation rejects an operator outside the vocabulary +(`startsWith`, `endsWith`, `mode`, `search` and the array/JSON operators are +gone). + +**Removed exports.** These have no replacement — the behaviour they carried is +either gone or now expressed in the Where vocabulary: + +| Removed | What to do instead | +| --------------------------------------------- | -------------------------------------------------------------- | +| `RELATIONSHIP_COUNT_FILTER_KEY` | Nothing — the count-filter marker no longer exists. | +| `RelationshipCountFilterMarker` | Nothing — same. | +| `resolveRelationshipCountFilters` | Nothing — a count filter shrinks to `some`/`none` when parsed. | +| `resolveRelationshipLabelFilters` | Nothing — a to-one label filter emits `some` directly. | +| `isToOneRelationshipField` | Read `many` off the relationship field config. | +| `ColumnEquality`, `UnsupportedPredicateError` | Gone with the predicate builder they belonged to. | + +**Newly exported:** `UndefinedAccessFilterError` and, from the secured surface, +`AccessFilterRecursionError`. diff --git a/docs/content/how-to/anonymous-access-control.md b/docs/content/how-to/anonymous-access-control.md index 62e7f520e..dcd423d8c 100644 --- a/docs/content/how-to/anonymous-access-control.md +++ b/docs/content/how-to/anonymous-access-control.md @@ -164,7 +164,9 @@ unconditionally is the mistake to avoid: ```typescript // ❌ Looks equivalent, isn't. Every access rule reasons about the shape of // its OWN filter — nothing evaluates `session.userId` for you and swaps in -// `false` when it's missing. +// `false` when it's missing. The engine refuses the read rather than running +// it: lowering a predicate is total, so a condition that resolved to +// `undefined` is an error, never a dropped clause. query: ({ session }) => ({ owner: { id: { equals: session?.userId } } }) // ✅ Deny outright when there's no session to scope to. diff --git a/docs/content/reference/config-api.md b/docs/content/reference/config-api.md index b5dfbefbf..8057036ab 100644 --- a/docs/content/reference/config-api.md +++ b/docs/content/reference/config-api.md @@ -502,10 +502,11 @@ query: ({ session }) => !!session // Filter: Users can only update their own posts update: ({ session, item }) => session?.userId === item.authorId -// Filter object: Scope access to specific records -query: ({ session }) => ({ - authorId: { equals: session?.userId }, -}) +// Filter object: Scope access to specific records. Deny outright when there is +// no session to scope to — the engine refuses a predicate that resolved to +// `undefined` rather than dropping it, so `{ authorId: session?.userId }` is +// an error for an anonymous caller, not a match-everything read. +query: ({ session }) => (session ? { authorId: { equals: session.userId } } : false) // Boolean only — create cannot be scoped by a filter create: ({ session }) => !!session diff --git a/packages/cli/src/mcp/lib/documentation-provider.ts b/packages/cli/src/mcp/lib/documentation-provider.ts index 4bb11681d..fe72e846e 100644 --- a/packages/cli/src/mcp/lib/documentation-provider.ts +++ b/packages/cli/src/mcp/lib/documentation-provider.ts @@ -323,7 +323,7 @@ access: { delete: isOwner, }, filter: { - query: ({ session }) => ({ userId: { equals: session?.userId } }), + query: ({ session }) => (session ? { userId: { equals: session.userId } } : false), }, } diff --git a/packages/core/src/access/engine.ts b/packages/core/src/access/engine.ts index cf8d42768..3edb0b966 100644 --- a/packages/core/src/access/engine.ts +++ b/packages/core/src/access/engine.ts @@ -1,7 +1,7 @@ import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js' import type { OpenSaasConfig, ListConfig, RelationshipField } from '../config/types.js' import { getSyntheticFieldName } from '../fields/index.js' -import { InvalidCreateAccessResultError } from './errors.js' +import { InvalidCreateAccessResultError, UndefinedAccessFilterError } from './errors.js' /** * Access engine — operation-level access control and shared helpers. @@ -200,6 +200,28 @@ export async function checkCreateAccess>( throw new InvalidCreateAccessResultError(listKey, result) } +/** + * The path to the first `undefined` condition in an access filter, or `null` + * when it carries none. Walks arrays too, so an `undefined` inside an `AND` + * or `OR` branch is found rather than folded in. + */ +function findUndefinedCondition(value: unknown, trail: readonly string[]): string[] | null { + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + const found = findUndefinedCondition(entry, [...trail, `${index}`]) + if (found !== null) return found + } + return null + } + if (typeof value !== 'object' || value === null || value instanceof Date) return null + for (const [key, entry] of Object.entries(value)) { + if (entry === undefined) return [...trail, key] + const found = findUndefinedCondition(entry, [...trail, key]) + if (found !== null) return found + } + return null +} + /** * Fold a {@link checkAccess} result into a caller's `where`, producing the * clause to hand the database — or `null` when access is denied. @@ -220,9 +242,21 @@ export async function checkCreateAccess>( * satisfy. A `PrismaFilter` from `checkAccess` is accepted here * unchanged; only the merged clause is untyped. * + * An access filter carrying an `undefined` condition anywhere throws + * {@link UndefinedAccessFilterError} rather than being handed on: the ORM + * reads `undefined` as "no constraint", so passing it through widens the read + * to every row. This is the same total-lowering rule the secured builder's + * Where vocabulary applies, so the guarantee holds on the legacy + * `findMany`/`count`/`updateMany`/`delete` paths too. The caller's own + * `userFilter` is left alone — it can only ever be narrowed by this clause, + * and `undefined` there is the caller's own optional key, not a scoping rule + * that failed to resolve. + * * @param userFilter - The caller's own `where`, if any. * @param accessFilter - What {@link checkAccess} returned. * @returns The clause to query with, or `null` when denied. + * @throws UndefinedAccessFilterError when the access filter has an + * `undefined` condition. */ export function mergeFilters( userFilter: PrismaFilter | undefined, @@ -232,6 +266,11 @@ export function mergeFilters( return null } + if (accessFilter !== true) { + const undefinedAt = findUndefinedCondition(accessFilter, []) + if (undefinedAt !== null) throw new UndefinedAccessFilterError(undefinedAt) + } + if (accessFilter === true) { return userFilter || {} } diff --git a/packages/core/src/access/errors.ts b/packages/core/src/access/errors.ts index a160dbc18..4fcd35713 100644 --- a/packages/core/src/access/errors.ts +++ b/packages/core/src/access/errors.ts @@ -240,3 +240,33 @@ export class InvalidCreateAccessResultError extends Error { this.listKey = listKey } } + +/** + * Thrown when an Access Filter carries an `undefined` condition — the shape + * `({ session }) => ({ authorId: session?.userId })` produces for an anonymous + * caller. The legacy read/update/delete paths hand the clause to the ORM, + * which reads an `undefined` value as "no constraint", so dropping it turns a + * scoping rule into a match-everything read (ADR-0022, ADR-0055). + * + * Deliberately distinct from `ValidationError`: this is a configuration fault + * in a trusted access rule, not caller input, and it must not be reported as a + * form error. It is the same refusal the secured builder's Where vocabulary + * makes, applied to the clause `mergeFilters` folds in, so the guarantee holds + * on every surface rather than only on `.where().all()/.first()`. + */ +export class UndefinedAccessFilterError extends Error { + public path: readonly string[] + + constructor(path: readonly string[]) { + const key = path.join('.') + super( + `An access rule returned a filter whose condition on "${key}" is undefined. A filter may ` + + `only narrow, so a condition that resolved to undefined is refused rather than dropped — ` + + `dropping it would match every row. An access rule that has nothing to scope by must ` + + `return \`false\` (deny) or \`true\` (allow) explicitly: write ` + + `\`({ session }) => (session ? { ${path[0] ?? 'ownerId'}: session.userId } : false)\`.`, + ) + this.name = 'UndefinedAccessFilterError' + this.path = path + } +} diff --git a/packages/core/src/access/index.ts b/packages/core/src/access/index.ts index 1caf0df5e..ed525517b 100644 --- a/packages/core/src/access/index.ts +++ b/packages/core/src/access/index.ts @@ -81,6 +81,8 @@ export { ResolveOutputCycleError } from './errors.js' export { InvalidFieldAccessResultError } from './errors.js' // Thrown when operation-level `create` access control returns a non-boolean result (#1009). export { InvalidCreateAccessResultError } from './errors.js' +// Thrown when an access rule returns a filter carrying an `undefined` condition (#1147). +export { UndefinedAccessFilterError } from './errors.js' // Thrown when a relation filter's related list denies query access outright (#916). export { RelationFilterAccessDeniedError } from './errors.js' // Thrown when a caller `include` names a key that is neither declared, synthetic, nor `_count` (#1082). diff --git a/packages/core/src/access/query-validation.ts b/packages/core/src/access/query-validation.ts index 83cc99aba..eef2b031b 100644 --- a/packages/core/src/access/query-validation.ts +++ b/packages/core/src/access/query-validation.ts @@ -3,6 +3,7 @@ import type { Session, AccessContext } from './types.js' import { getRelatedListConfig } from './engine.js' import { isFieldReadableForPredicate } from './field-access.js' import { ValidationError } from '../hooks/index.js' +import { SCALAR_OPERATORS, SCALAR_OPERATOR_SET } from '../secured/operators.js' /** * #912 — read-path key validation. @@ -150,6 +151,22 @@ export function resolveQueryField( return undefined } +/** + * Refuse an operator the Where vocabulary does not contain. A scalar + * condition is either a bare value or an object whose keys are all operators, + * so anything else is a caller error rather than a nested field name. + */ +function rejectUnknownOperators(listName: string, key: string, condition: unknown): void { + if (condition === null || typeof condition !== 'object' || Array.isArray(condition)) return + for (const operator of Object.keys(condition)) { + if (SCALAR_OPERATOR_SET.has(operator)) continue + throw new ValidationError([ + `Cannot query "${listName}" — "${key}" was given "${operator}", which is not part of the ` + + `Where vocabulary (${SCALAR_OPERATORS.join(', ')}).`, + ]) + } +} + function rejectUndeclaredKey(listName: string, key: string, kind: 'where' | 'orderBy'): never { throw new ValidationError([ `Cannot query "${listName}" — "${key}" is not a field of this list. ` + @@ -223,16 +240,17 @@ function walkWhere( rejectUndeclaredKey(listName, key, 'where') } - // Scalar field filters use Prisma's own operator vocabulary (`equals`, - // `contains`, `in`, …) and never nest another field name — trusted as-is, - // no further walk needed. Only a relationship field's filter nests a - // WHERE clause for another list. - if ( - resolved.isRelationship && - value !== null && - typeof value === 'object' && - !Array.isArray(value) - ) { + // A scalar field's filter never nests another field name, but it does + // name operators, and the Where vocabulary is closed (ADR-0055): an + // operator outside it is refused rather than passed to the ORM, where an + // unhandled one would widen the clause. Only a relationship field's + // filter nests a WHERE clause for another list. + if (!resolved.isRelationship) { + rejectUnknownOperators(listName, key, value) + continue + } + + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const related = getRelatedListConfig(resolved.fieldConfig.ref, config) if (!related) continue diff --git a/packages/core/src/access/relationship-count.test.ts b/packages/core/src/access/relationship-count.test.ts index eda18284f..927f44caa 100644 --- a/packages/core/src/access/relationship-count.test.ts +++ b/packages/core/src/access/relationship-count.test.ts @@ -1,21 +1,17 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { list } from '../config/index.js' import { text, relationship, integer } from '../fields/index.js' import type { OpenSaasConfig } from '../config/types.js' import type { AccessContext } from './types.js' -import { - buildRelationshipCountSelect, - resolveRelationshipCountFilters, - isToManyRelationshipField, -} from './relationship-count.js' -import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js' +import { buildRelationshipCountSelect, isToManyRelationshipField } from './relationship-count.js' /** * Access-scoped to-many relationship counts (issue #732). Verifies: * • the filtered `_count` select folds in the related list's query access, - * • a denied related list is omitted (its count renders as 0, never a leak), - * • count-filter markers resolve to access-scoped `{ id: { in } }` via a single - * secured read (never a per-row query), including the fully-denied case. + * • a denied related list is omitted (its count renders as 0, never a leak). + * + * The count-filter resolver is gone with ADR-0055: a count comparison shrinks + * to presence, which the engine's own lowering handles. */ // User (published-only for anon via a filter), plus a Widget list that is fully @@ -45,14 +41,12 @@ function makeConfig(): OpenSaasConfig { } as any } -function makeContext( - findMany?: (args: unknown) => Promise>>, -): AccessContext { +function makeContext(): AccessContext { return { session: null, _isSudo: false, _resolveOutputChain: [], - db: findMany ? { User: { findMany } } : {}, + db: {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test } as any } @@ -112,158 +106,3 @@ describe('buildRelationshipCountSelect', () => { expect(select).toBeUndefined() }) }) - -describe('resolveRelationshipCountFilters', () => { - const marker = (operator: string, value: number) => ({ - posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator, value } }, - }) - - it('returns the where unchanged when there are no count markers', async () => { - const config = makeConfig() - const where = { name: { contains: 'ada' } } - const resolved = await resolveRelationshipCountFilters( - where, - config.lists.User, - 'User', - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBe(where) - }) - - it('resolves a count marker to an access-scoped { id: { in } } via a single secured read', async () => { - const config = makeConfig() - // Two users; the secured read returns each with its access-scoped `_count`. - const findMany = vi.fn(async () => [ - { id: 'u1', _count: { posts: 3 } }, - { id: 'u2', _count: { posts: 7 } }, - ]) - const resolved = await resolveRelationshipCountFilters( - marker('gt', 5), - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - // Only u2 (7 > 5) matches; the read was access-scoped (published-only count). - expect(resolved).toEqual({ id: { in: ['u2'] } }) - expect(findMany).toHaveBeenCalledTimes(1) - expect(findMany).toHaveBeenCalledWith({ - include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } }, - }) - }) - - it('includes zero-count rows for comparisons that admit zero (no complement bug)', async () => { - const config = makeConfig() - const findMany = vi.fn(async () => [ - { id: 'u1', _count: { posts: 0 } }, - { id: 'u2', _count: { posts: 4 } }, - ]) - const resolved = await resolveRelationshipCountFilters( - marker('lt', 1), // count < 1 → only the zero-count user - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - expect(resolved).toEqual({ id: { in: ['u1'] } }) - }) - - it('resolves a denied related list without any query: count is always 0', async () => { - const config = makeConfig() - const findMany = vi.fn(async () => []) - // `widgets`' related list (Widget) is fully denied → every count is 0. - const where = { widgets: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } } } - const resolved = await resolveRelationshipCountFilters( - where, - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - // 0 is not > 5 → nothing matches, and no read was issued. - expect(resolved).toEqual({ id: { in: [] } }) - expect(findMany).not.toHaveBeenCalled() - }) - - it('preserves sibling conditions, ANDing the resolved id constraint', async () => { - const config = makeConfig() - const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]) - const where = { AND: [{ name: { contains: 'ada' } }, marker('gte', 1)] } - const resolved = await resolveRelationshipCountFilters( - where, - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - expect(resolved).toEqual({ AND: [{ name: { contains: 'ada' } }, { id: { in: ['u2'] } }] }) - }) - - it('reads with only the filtered `_count` include — no over-fetching `select` projection', async () => { - const config = makeConfig() - const findMany = vi.fn((_args: unknown) => - Promise.resolve([{ id: 'u2', _count: { posts: 7 } }]), - ) - await resolveRelationshipCountFilters( - marker('gt', 5), - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - // The secured read is issued with the access-scoped `_count` include and NO - // `select` — the secured findMany does not honour `select`, so forcing one - // would be an ignored no-op. Access-scoping lives entirely in `_count.where`. - expect(findMany).toHaveBeenCalledTimes(1) - const callArg = findMany.mock.calls[0][0] - expect(callArg).not.toHaveProperty('select') - expect(callArg).toEqual({ - include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } }, - }) - }) - - it('preserves a sibling condition co-present in the same member as the marker', async () => { - const config = makeConfig() - const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]) - // A single AND-member carrying BOTH a scalar condition and the count marker. - // The marker resolution must keep the co-present sibling, not replace the - // member wholesale (guards against a future filter-engine change that merges - // conditions into one member). - const where = { - AND: [ - { - name: { contains: 'ada' }, - posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } }, - }, - ], - } - const resolved = await resolveRelationshipCountFilters( - where, - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - expect(resolved).toEqual({ name: { contains: 'ada' }, id: { in: ['u2'] } }) - }) - - it('ANDs the resolved id constraint with a co-present sibling id condition (no silent drop)', async () => { - const config = makeConfig() - const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]) - // Contrived: a member carrying its own `id` condition alongside the marker. - // Spreading would let one `id` overwrite the other; instead both are ANDed. - const where = { - id: { in: ['u1', 'u2'] }, - posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } }, - } - const resolved = await resolveRelationshipCountFilters( - where, - config.lists.User, - 'User', - { session: null, context: makeContext(findMany) }, - config, - ) - expect(resolved).toEqual({ AND: [{ id: { in: ['u1', 'u2'] } }, { id: { in: ['u2'] } }] }) - }) -}) diff --git a/packages/core/src/access/relationship-count.ts b/packages/core/src/access/relationship-count.ts index c1e3484c6..347350812 100644 --- a/packages/core/src/access/relationship-count.ts +++ b/packages/core/src/access/relationship-count.ts @@ -1,7 +1,5 @@ import type { Session, AccessContext, PrismaFilter } from './types.js' import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js' -import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js' -import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js' import { checkAccess, getRelatedListConfig } from './engine.js' /** @@ -16,12 +14,10 @@ import { checkAccess, getRelatedListConfig } from './engine.js' * operation-level `query` access is folded into that `_count`, mirroring how * `buildAccessScopedInclude` folds it into relation includes. * - * It also resolves the count Filter spec's markers: Prisma cannot compare a - * relation count in a `where`, so a to-many relationship's Filter spec emits a - * {@link RELATIONSHIP_COUNT_FILTER_KEY} marker that - * {@link resolveRelationshipCountFilters} turns into an access-scoped - * `{ id: { in } }` before the query runs — never leaking counts of related rows - * the session cannot see. + * A count comparison is no longer expressible as a filter: Prisma 8 cannot + * compare a relation count in a `where`, and the id-list resolver that used to + * fake one is gone. A to-many Filter spec emits `some`/`none` for presence and + * degrades any other comparison to free text (ADR-0055). */ type CountArgs = { @@ -114,206 +110,3 @@ export async function buildRelationshipCountSelect( } return Object.keys(select).length > 0 ? select : undefined } - -function readRelationshipCount(row: Record, fieldName: string): number { - const counts = row._count - if (counts && typeof counts === 'object') { - const value = (counts as Record)[fieldName] - if (typeof value === 'number') return value - } - return 0 -} - -function matchesCount(count: number, operator: FilterOperator, value: number): boolean { - switch (operator) { - case 'eq': - return count === value - case 'gt': - return count > value - case 'gte': - return count >= value - case 'lt': - return count < value - case 'lte': - return count <= value - } -} - -/** Minimal shape the resolver needs off the secured `context.db` delegate. */ -interface CountFindManyDelegate { - findMany: (args: { include: { _count: { select: Record } } }) => Promise -} - -function asCountDelegate(value: unknown): CountFindManyDelegate | null { - if (value && typeof value === 'object' && 'findMany' in value) { - const candidate = value as { findMany?: unknown } - if (typeof candidate.findMany === 'function') { - return value as CountFindManyDelegate - } - } - return null -} - -function readCountMarker(value: unknown): RelationshipCountFilterMarker | null { - if (!value || typeof value !== 'object') return null - const marker = (value as Record)[RELATIONSHIP_COUNT_FILTER_KEY] - if (!marker || typeof marker !== 'object') return null - const { operator, value: n } = marker as { operator?: unknown; value?: unknown } - if ( - (operator === 'eq' || - operator === 'gt' || - operator === 'gte' || - operator === 'lt' || - operator === 'lte') && - typeof n === 'number' - ) { - return { operator, value: n } - } - return null -} - -/** - * Resolve one to-many relationship count-filter marker into a Prisma `where` - * fragment constraining the parent by id. Runs a single access-scoped read - * through the SECURED context — never a raw/unscoped query and never a per-row - * query — computing the access-visible count per parent and keeping the ids - * whose count satisfies the comparison. A fully-denied related list makes every - * count 0, resolved without any query. - */ -async function resolveOneCountFilter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo - listConfig: ListConfig, - listKey: string, - fieldName: string, - marker: RelationshipCountFilterMarker, - args: CountArgs, - config: OpenSaasConfig, -): Promise { - const field = listConfig.fields[fieldName] - const entry = await relationshipCountAccessEntry(field, args, config) - - // Not resolvable (shouldn't happen for a marker the spec emitted) → no-op. - if (entry === null) return {} - - // Related list fully denied → every parent's access-visible count is 0. - if (entry.kind === 'denied') { - return matchesCount(0, marker.operator, marker.value) ? {} : { id: { in: [] } } - } - - const delegate = asCountDelegate(args.context.db[listKey]) - if (!delegate) return {} - - const countSelect = entry.kind === 'scoped' ? { where: entry.where } : true - // This over-fetches the parent's scalar columns — it reads only `id` + `_count` - // per row yet materialises every scalar. It is left un-narrowed on purpose: the - // secured `context.db` `findMany` does NOT honour Prisma `select` (it - // warns-and-ignores it and returns the full access-filtered record — see - // `warnIfSelectIgnored` in context/index.ts and the "Narrowing Reads" note in - // packages/core/CLAUDE.md). The only supported narrowing is `include`/fragment - // `query`, neither of which can drop scalar columns. So adding - // `select: { id: true, _count: {...} }` here would be a silent no-op that also - // trips the ignore-warning, not a real projection. The count stays access-scoped - // via the filtered `_count` include below regardless; trimming the projection - // would first require the read pipeline to honour `select`. - const rows = await delegate.findMany({ - include: { _count: { select: { [fieldName]: countSelect } } }, - }) - - const matchingIds: string[] = [] - if (Array.isArray(rows)) { - for (const row of rows) { - if (!row || typeof row !== 'object') continue - const record = row as Record - if (matchesCount(readRelationshipCount(record, fieldName), marker.operator, marker.value)) { - matchingIds.push(String(record.id)) - } - } - } - return { id: { in: matchingIds } } -} - -/** - * Merge a filter-member's non-resolved sibling conditions with a resolved - * access-scoped fragment (e.g. a count marker's `{ id: { in } }`, or a to-one - * label filter's access-scoped `is`). With no siblings (the guaranteed case - * today) this is just the resolved fragment. When a sibling shares a key with - * the resolved fragment — a contrived case that cannot arise under the current - * one-condition-per-member invariant — both are ANDed so neither condition is - * silently lost. Shared by both relationship resolvers in this module and in - * `relationship-label-filter.ts`. - */ -export function mergeResolvedMember( - siblings: Record, - resolved: Record, -): Record { - if (Object.keys(siblings).length === 0) return resolved - const collides = Object.keys(resolved).some((key) => key in siblings) - return collides ? { AND: [siblings, resolved] } : { ...siblings, ...resolved } -} - -/** - * Replace any to-many relationship count-filter markers in a filter `where` with - * access-scoped `{ id: { in } }` fragments. Markers only ever appear as - * top-level AND members (the pure filter engine pushes each field condition into - * the top-level AND, and never nests a marker inside the free-text OR), so this - * walks only the top level. Returns the `where` unchanged when it contains no - * markers, so lists without count filters pay nothing. - */ -export async function resolveRelationshipCountFilters( - where: Record | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo - listConfig: ListConfig, - listKey: string, - args: CountArgs, - config: OpenSaasConfig, -): Promise | undefined> { - if (!where) return where - - const andValue = where.AND - const members: Array> = Array.isArray(andValue) - ? (andValue as Array>) - : [where] - - const findMarker = ( - member: Record, - ): { field: string; marker: RelationshipCountFilterMarker } | null => { - for (const key of Object.keys(member)) { - if (!isToManyRelationshipField(listConfig.fields[key])) continue - const marker = readCountMarker(member[key]) - if (marker) return { field: key, marker } - } - return null - } - - if (!members.some((member) => findMarker(member) !== null)) { - return where - } - - const resolvedMembers: Array> = [] - for (const member of members) { - const found = findMarker(member) - if (!found) { - resolvedMembers.push(member) - continue - } - const resolved = await resolveOneCountFilter( - listConfig, - listKey, - found.field, - found.marker, - args, - config, - ) - // Preserve any sibling conditions rather than replacing the member - // wholesale — see `mergeResolvedMember` above for why. - const siblings: Record = { ...member } - delete siblings[found.field] - resolvedMembers.push(mergeResolvedMember(siblings, resolved)) - } - - // Drop no-op members (a fully-denied related list where 0 satisfies the - // comparison resolves to `{}` — matches everything, so it need not be ANDed). - const effective = resolvedMembers.filter((member) => Object.keys(member).length > 0) - if (effective.length === 0) return undefined - return effective.length === 1 ? effective[0] : { AND: effective } -} diff --git a/packages/core/src/access/relationship-label-filter.test.ts b/packages/core/src/access/relationship-label-filter.test.ts deleted file mode 100644 index 7c9965130..000000000 --- a/packages/core/src/access/relationship-label-filter.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { list } from '../config/index.js' -import { text, relationship, integer } from '../fields/index.js' -import type { OpenSaasConfig } from '../config/types.js' -import type { AccessContext } from './types.js' -import { - resolveRelationshipLabelFilters, - isToOneRelationshipField, -} from './relationship-label-filter.js' - -/** - * `resolveRelationshipLabelFilters` used to fold the related list's `query` - * access into a to-one relationship label filter's nested `is` clause - * (issue #749). Since #916, the engine itself scopes every relation filter in - * `where` (`buildAccessScopedWhere`), including this exact `{ is: {...} } }` - * shape, so this resolver's own fold is redundant and has been removed — it - * is now a pass-through, kept exported only for API compatibility. These - * tests pin that pass-through behavior; `access-filter.test.ts` and - * `context.test.ts` cover the actual access-scoping this resolver used to do. - */ - -function makeConfig(): OpenSaasConfig { - return { - db: { provider: 'postgresql' }, - lists: { - User: list({ - fields: { name: text(), active: integer() }, - access: { operation: { query: () => ({ active: { equals: 1 } }) } }, - }), - Post: list({ - fields: { - title: text(), - views: integer(), - author: relationship({ ref: 'User.posts' }), - widget: relationship({ ref: 'Widget' }), - }, - access: { operation: { query: () => true } }, - }), - // Widget ships closed (no access block) → query denied by default. - Widget: list({ fields: { name: text() } }), - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test - } as any -} - -function makeContext(): AccessContext { - return { - session: null, - _isSudo: false, - _resolveOutputChain: [], - db: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test - } as any -} - -describe('isToOneRelationshipField', () => { - const config = makeConfig() - const postFields = config.lists.Post.fields - - it('is true only for a to-one relationship', () => { - expect(isToOneRelationshipField(postFields.author)).toBe(true) - expect(isToOneRelationshipField(postFields.widget)).toBe(true) - expect(isToOneRelationshipField(postFields.title)).toBe(false) - }) -}) - -describe('resolveRelationshipLabelFilters (pass-through since #916)', () => { - it('returns a plain where unchanged', async () => { - const config = makeConfig() - const where = { title: { contains: 'hello' } } - const resolved = await resolveRelationshipLabelFilters( - where, - config.lists.Post, - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBe(where) - }) - - it('returns a to-one label-filter `is` clause unchanged — the engine scopes it now', async () => { - const config = makeConfig() - const where = { author: { is: { name: { contains: 'Ada' } } } } - const resolved = await resolveRelationshipLabelFilters( - where, - config.lists.Post, - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBe(where) - }) - - it('returns a label filter against a fully denied related list unchanged too', async () => { - const config = makeConfig() - const where = { widget: { is: { name: { contains: 'Gadget' } } } } - const resolved = await resolveRelationshipLabelFilters( - where, - config.lists.Post, - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBe(where) - }) - - it('returns a where with nested AND/label-filter members unchanged', async () => { - const config = makeConfig() - const where = { - AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }], - } - const resolved = await resolveRelationshipLabelFilters( - where, - config.lists.Post, - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBe(where) - }) - - it('returns undefined unchanged when there is no where at all', async () => { - const config = makeConfig() - const resolved = await resolveRelationshipLabelFilters( - undefined, - config.lists.Post, - { session: null, context: makeContext() }, - config, - ) - expect(resolved).toBeUndefined() - }) -}) diff --git a/packages/core/src/access/relationship-label-filter.ts b/packages/core/src/access/relationship-label-filter.ts deleted file mode 100644 index 3b4b7d52c..000000000 --- a/packages/core/src/access/relationship-label-filter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Session, AccessContext } from './types.js' -import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js' - -/** - * Access-scoped to-one relationship label filters for the admin list view - * (issue #749) — NOW A PASS-THROUGH. - * - * A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name: - * { contains: 'Ada' } } } }`) produces exactly the `{ is: {...} }` shape a - * relation filter uses. Before #916, the engine did not scope relation - * filters in `where` at all, so this module was the only place the related - * list's `query` access was folded into that nested `is` clause (mirroring - * `relationship-count.ts`'s `_count` folding) — otherwise a session could - * distinguish parent rows by a related field it could not itself read (e.g. - * binary-searching `author:A`, `author:Ad`, `author:Ada` against a `User` - * list it cannot query). - * - * #916 closed that gap in the engine itself: `context.db.*.findMany`/`count` - * now scope every relation filter in `where` — including this exact `is` - * shape — via `buildAccessScopedWhere` (`access-filter.ts`), applied - * automatically to whatever `where` this module's caller (`ListView.tsx`) - * hands to the secured context. Folding the same access filter here as well - * would be redundant work producing an identical result (the engine ANDs the - * same filter in a second time), so this module's own fold was removed — - * `resolveRelationshipLabelFilters` now returns `where` unchanged. The - * exported functions are kept, unchanged in shape, only because they remain - * part of `@opensaas/stack-core`'s public surface; removing them outright - * would be a breaking change this fix does not need to make. - */ - -type LabelFilterArgs = { - session: Session | null - context: AccessContext -} - -/** - * Whether a field is a to-one relationship — the only field kind whose Filter - * spec emits a nested `{ is: {...} } }` condition against the related list's - * label field. - */ -export function isToOneRelationshipField(field: FieldConfig | undefined): boolean { - return ( - field?.type === 'relationship' && - !('many' in field && field.many === true) && - 'ref' in field && - typeof field.ref === 'string' && - field.ref.length > 0 - ) -} - -/** - * No longer folds access into label filters — see the module doc above. - * Returns `where` unchanged. - */ -export async function resolveRelationshipLabelFilters( - where: Record | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo, kept for API compatibility (see module doc) - _listConfig: ListConfig, - _args: LabelFilterArgs, - _config: OpenSaasConfig, -): Promise | undefined> { - return where -} diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index 162d26642..1b1db3189 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -1014,6 +1014,7 @@ export function populateDbDelegate( } else { const read = createSecuredRead({ listName, listConfig, ormHandle, context, config }) operations.where = read.where + operations.orderBy = read.orderBy operations.all = read.all operations.first = read.first } diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index 585e33b99..ca5946482 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -27,7 +27,6 @@ import { hashPassword, isHashedPassword, HashedPassword } from '../utils/passwor import { formatPrismaDefault } from './format-prisma-default.js' import { getLabelFieldName } from '../config/label.js' import type { FilterOperator, FilterSpec } from '../filter/types.js' -import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js' /** Operators shared by numeric/date fields' `getFilterSpec`. */ const COMPARISON_OPERATORS: FilterOperator[] = ['eq', 'gt', 'gte', 'lt', 'lte'] @@ -1735,17 +1734,16 @@ export function relationship< field.getContractField = (fieldName: string, listKey: string, config: OpenSaasConfig) => getContractRelation(field, fieldName, listKey, config) - // Relationships filter differently by cardinality (issue #732): + // Relationships filter differently by cardinality (issue #732, ADR-0055): // • to-one filters by the related Item's label — `author:"Ada Lovelace"` - // becomes a nested `is` `contains` on the target list's Label field. - // • to-many filters by the access-visible related COUNT with numeric - // comparisons — `orders:>5`. Prisma cannot compare a relation count in a - // `where`, so the mapper emits a structured count marker - // (RELATIONSHIP_COUNT_FILTER_KEY) that `resolveRelationshipCountFilters` - // later turns into an access-scoped `{ id: { in } }`. - // The mapper stays pure in both cases (no DB lookup). Suggestions point a - // to-one at the target list's label lookup; a to-many exposes no value source - // (a numeric compare, like an integer field). + // becomes a `some` over the target list's Label field. Prisma 8 has no + // to-one predicate form: every relation predicate is an EXISTS, and its + // accessor is the same for both cardinalities. + // • to-many filters by PRESENCE. Prisma 8 cannot compare a relation count + // in a `where`, so `orders:0` is `none`, `orders:>0`/`orders:>=1` are + // `some`, and any other comparison degrades to free text. + // The mapper stays pure in both cases (no DB lookup); the engine ANDs the + // related list's own `query` access inside the EXISTS when it lowers this. field.getFilterSpec = ( _fieldName: string, _listKey: string, @@ -1760,12 +1758,12 @@ export function relationship< operators: COMPARISON_OPERATORS, toCondition: (operator, value) => { const trimmed = value.trim() - // A non-integer count comparison can't be interpreted → degrade to - // free text (matching the integer field's behaviour). if (!/^-?\d+$/.test(trimmed)) return null - return { - [_fieldName]: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator, value: Number(trimmed) } }, - } + const count = Number(trimmed) + if (operator === 'eq' && count === 0) return { [_fieldName]: { none: {} } } + if (operator === 'gt' && count === 0) return { [_fieldName]: { some: {} } } + if (operator === 'gte' && count === 1) return { [_fieldName]: { some: {} } } + return null }, suggestions: { valueSource: { kind: 'none' } }, } @@ -1781,7 +1779,7 @@ export function relationship< operators: ['eq'], toCondition: (operator, value) => { if (operator !== 'eq') return null - return { [_fieldName]: { is: { [labelField]: { contains: value } } } } + return { [_fieldName]: { some: { [labelField]: { contains: value } } } } }, suggestions: { valueSource: { kind: 'relationship', listKey: targetList, many: false } }, } diff --git a/packages/core/src/filter/collect.ts b/packages/core/src/filter/collect.ts index 8365347dd..16f9554af 100644 --- a/packages/core/src/filter/collect.ts +++ b/packages/core/src/filter/collect.ts @@ -7,8 +7,7 @@ import type { FilterCondition, FilterFieldSuggestion, FilterSpec } from './types /** * The session/context a field's `read` access is evaluated against — the same - * shape every other access-scoped admin-UI helper takes (e.g. - * `resolveRelationshipCountFilters`, `resolveRelationshipLabelFilters`). + * shape every other access-scoped admin-UI helper takes. */ export type FilterAccessArgs = { session: Session | null diff --git a/packages/core/src/filter/filter.test.ts b/packages/core/src/filter/filter.test.ts index d3811a20c..77abf646a 100644 --- a/packages/core/src/filter/filter.test.ts +++ b/packages/core/src/filter/filter.test.ts @@ -332,10 +332,13 @@ describe('core field Filter specs', () => { expect(spec.toCondition('gt', 'not-a-date')).toBeNull() }) - it('relationship maps to a nested label filter (is for to-one)', () => { + it('relationship maps to a nested label filter (some, for either cardinality)', () => { const spec = postConfig.fields.author.getFilterSpec!('author', 'Post', config)! - // User's label field falls back to `name`. - expect(spec.toCondition('eq', 'Ada')).toEqual({ author: { is: { name: { contains: 'Ada' } } } }) + // User's label field falls back to `name`. Prisma 8 has no to-one + // predicate form, so a label match is a `some` (ADR-0055). + expect(spec.toCondition('eq', 'Ada')).toEqual({ + author: { some: { name: { contains: 'Ada' } } }, + }) expect(spec.suggestions.valueSource).toEqual({ kind: 'relationship', listKey: 'User', @@ -410,7 +413,7 @@ describe('buildListFilterWhere (end-to-end over a real list config)', () => { AND: [ { status: { equals: 'published' } }, { views: { gt: 10 } }, - { author: { is: { name: { contains: 'Ada' } } } }, + { author: { some: { name: { contains: 'Ada' } } } }, // `hello` is free text over the two text fields (title only — status is // not free-text) → but title is the only free-text field here. { title: { contains: 'hello' } }, @@ -450,7 +453,7 @@ describe('buildListFilterWhere (end-to-end over a real list config)', () => { }) }) -describe('to-many relationship Filter spec (count comparisons — issue #732)', () => { +describe('to-many relationship Filter spec (presence — issue #732, ADR-0055)', () => { function countConfig(): OpenSaasConfig { return { db: { provider: 'postgresql' }, @@ -469,32 +472,30 @@ describe('to-many relationship Filter spec (count comparisons — issue #732)', } as OpenSaasConfig } - it('supports numeric comparisons and emits a count marker (not a label filter)', () => { + it('shrinks a count comparison to presence, and degrades the rest', () => { const config = countConfig() const spec = config.lists.User.fields.posts.getFilterSpec!('posts', 'User', config)! expect(spec.operators).toEqual(['eq', 'gt', 'gte', 'lt', 'lte']) - expect(spec.toCondition('gt', '5')).toEqual({ - posts: { _countFilter: { operator: 'gt', value: 5 } }, - }) - expect(spec.toCondition('eq', '0')).toEqual({ - posts: { _countFilter: { operator: 'eq', value: 0 } }, - }) - // A non-integer count degrades to free text (like an integer field). + expect(spec.toCondition('eq', '0')).toEqual({ posts: { none: {} } }) + expect(spec.toCondition('gt', '0')).toEqual({ posts: { some: {} } }) + expect(spec.toCondition('gte', '1')).toEqual({ posts: { some: {} } }) + // Prisma 8 cannot compare a relation count in a `where`, so a bookmarked + // `posts:>5` stops filtering rather than erroring — it degrades to free + // text under ADR-0017's own degradation rule. + expect(spec.toCondition('gt', '5')).toBeNull() + expect(spec.toCondition('lt', '3')).toBeNull() expect(spec.toCondition('gt', 'lots')).toBeNull() - // No enumerated value source — a count is a plain numeric compare. expect(spec.suggestions.valueSource).toEqual({ kind: 'none' }) }) - it('end-to-end: buildListFilterWhere carries a count marker for `posts:>5`', async () => { + it('end-to-end: `posts:>0` becomes a presence filter and `posts:>5` degrades', async () => { const config = countConfig() - const where = await buildListFilterWhere( - 'posts:>5', - config.lists.User, - 'User', - config, - noAccessArgs, - ) - expect(where).toEqual({ posts: { _countFilter: { operator: 'gt', value: 5 } } }) + expect( + await buildListFilterWhere('posts:>0', config.lists.User, 'User', config, noAccessArgs), + ).toEqual({ posts: { some: {} } }) + expect( + await buildListFilterWhere('posts:>5', config.lists.User, 'User', config, noAccessArgs), + ).toEqual({ name: { contains: '5' } }) }) }) diff --git a/packages/core/src/filter/index.ts b/packages/core/src/filter/index.ts index 60bef2b9d..40742a0c5 100644 --- a/packages/core/src/filter/index.ts +++ b/packages/core/src/filter/index.ts @@ -14,7 +14,6 @@ export { serializeFilterQuery } from './serialize.js' export { buildFilterWhere } from './map.js' export { collectFilterSpecs, buildListFilterWhere, collectFilterSuggestions } from './collect.js' export type { FilterAccessArgs } from './collect.js' -export { RELATIONSHIP_COUNT_FILTER_KEY } from './types.js' export type { FilterOperator, FilterToken, @@ -22,5 +21,4 @@ export type { FilterSpec, FilterValueSource, FilterFieldSuggestion, - RelationshipCountFilterMarker, } from './types.js' diff --git a/packages/core/src/filter/purity.test.ts b/packages/core/src/filter/purity.test.ts new file mode 100644 index 000000000..9d0393c02 --- /dev/null +++ b/packages/core/src/filter/purity.test.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const src = path.resolve(here, '..') + +interface Edge { + typeOnly: boolean + specifier: string +} + +/** + * `import`/`export ... from '…'` and bare `import '…'`. A `type` immediately + * after the keyword marks an edge that is erased at build time and therefore + * pulls nothing into the module graph; a lazy `import('…')` call is not + * matched, which is the point — the invariant is about the STATIC graph. + */ +const EDGE = /(?:^|\n)\s*(?:import|export)(\s+type)?\s+(?:[^'";]*?\s+from\s+)?'([^']+)'/g + +function edges(source: string): Edge[] { + return [...source.matchAll(EDGE)].map((match) => ({ + typeOnly: match[1] !== undefined, + specifier: match[2], + })) +} + +/** Every file reachable from `roots` by a runtime relative import. */ +function graph(roots: readonly string[]): { files: Set; orm: string[] } { + const files = new Set() + const orm: string[] = [] + const stack = [...roots] + while (stack.length > 0) { + const file = stack.pop() + if (file === undefined || files.has(file)) continue + files.add(file) + for (const { typeOnly, specifier } of edges(readFileSync(file, 'utf8'))) { + if (typeOnly) continue + if (!specifier.startsWith('.')) { + if (specifier.startsWith('@prisma/')) orm.push(`${path.relative(src, file)} → ${specifier}`) + continue + } + const resolved = path.resolve(path.dirname(file), specifier.replace(/\.js$/, '.ts')) + expect(existsSync(resolved), `${file} imports ${specifier}, which resolves nowhere`).toBe( + true, + ) + stack.push(resolved) + } + } + return { files, orm } +} + +function sources(dir: string): string[] { + return readdirSync(dir) + .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) + .map((name) => path.join(dir, name)) +} + +/** + * The filter engine is the pure boundary the URL grammar is unit-tested + * through (ADR-0017), and ADR-0055 keeps it that way by making its condition + * type a Where vocabulary value rather than an ORM fragment. The vocabulary + * module it names is held to the same rule, so both are walked as one graph + * rather than checked file by file — a new module added to either is covered + * the moment something imports it. + */ +describe('the pure boundary', () => { + const roots = [ + ...sources(here), + path.join(src, 'secured', 'vocabulary.ts'), + path.join(src, 'secured', 'operators.ts'), + ] + + it('imports nothing from the ORM, anywhere in its real import graph', () => { + const { files, orm } = graph(roots) + expect(orm).toEqual([]) + // Not vacuous: the walk followed the boundary's edges out of the module, + // so an empty result above is the invariant holding rather than a walk + // that visited only the roots it was handed. + expect(files.size).toBeGreaterThan(roots.length) + }) + + it('the walk detects an ORM import: the contract builder carries one', () => { + expect(graph([path.join(src, 'contract', 'prisma.ts')]).orm.length).toBeGreaterThan(0) + }) + + it('names the count-filter symbols nowhere: they are deletions (ADR-0055)', () => { + const source = sources(here) + .map((file) => readFileSync(file, 'utf8')) + .join('\n') + for (const symbol of [ + 'RELATIONSHIP_COUNT_FILTER_KEY', + 'RelationshipCountFilterMarker', + 'resolveRelationshipCountFilters', + 'resolveRelationshipLabelFilters', + ]) { + expect(source).not.toContain(symbol) + } + }) +}) diff --git a/packages/core/src/filter/types.ts b/packages/core/src/filter/types.ts index cace81bf4..7e3e824b4 100644 --- a/packages/core/src/filter/types.ts +++ b/packages/core/src/filter/types.ts @@ -12,6 +12,8 @@ * `parse → tokens` and `tokens + specs → conditions` seam is unit-testable. */ +import type { Where } from '../secured/vocabulary.js' + /** * A Filter operator. `eq` is the default operator for a plain `field:value` * token (each field's spec decides what equality means — `contains` for text, @@ -39,12 +41,15 @@ export interface FilterToken { } /** - * A Prisma `where` fragment produced by a Filter spec's `toCondition` mapper. - * Kept as an opaque record so this module carries no Prisma type dependency; - * the secured context ANDs it with the access filter when the fragment reaches - * `context.db.*`. + * A predicate in the secured surface's Where vocabulary, produced by a Filter + * spec's `toCondition` mapper (ADR-0055). The vocabulary is data, declared in + * a module that imports nothing from the ORM, which is what keeps this seam — + * and the URL grammar built on it — unit-testable. + * + * The secured context ANDs it with the Access Filter when it reaches + * `context.db..where(...)`, so a filter can only ever narrow. */ -export type FilterCondition = Record +export type FilterCondition = Where /** * Serializable description of what the suggestion dropdown may offer for a @@ -58,26 +63,6 @@ export type FilterValueSource = | { kind: 'enum'; options: Array<{ value: string; label: string }> } | { kind: 'relationship'; listKey: string; many: boolean } -/** - * Marker key a to-many relationship's Filter spec emits for a count comparison - * (`orders:>5`). Prisma cannot express a relation-count comparison in a `where` - * (there is no `{ orders: { _count: { gt: 5 } } }`), so the pure spec can only - * emit a structured marker; `resolveRelationshipCountFilters` later turns each - * marker into an access-scoped `{ id: { in | notIn } }` before the query runs. - * Kept here (the pure boundary) so the field builder and the resolver agree on - * the shape without depending on each other. - */ -export const RELATIONSHIP_COUNT_FILTER_KEY = '_countFilter' as const - -/** - * The payload a {@link RELATIONSHIP_COUNT_FILTER_KEY} marker carries: the - * numeric comparison to apply to a to-many relationship's access-visible count. - */ -export interface RelationshipCountFilterMarker { - operator: FilterOperator - value: number -} - /** * A field's self-declared filtering capability. Returned by the optional * `getFilterSpec` field-builder method. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e4370675..e5e85c35c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,10 +130,22 @@ export type { export type { StackContext } from './types/context.js' // The secured read surface: the composed query value `context.db.` is, -// its predicate vocabulary, and the refusal a predicate the engine cannot -// lower raises (ADR-0041, ADR-0055). -export { UnsupportedPredicateError, SecuredCollectionMissingError } from './secured/read.js' -export type { SecuredQuery, Where, WhereCondition, WhereValue } from './secured/read.js' +// and the closed Where vocabulary it takes (ADR-0041, ADR-0055). +export { SecuredCollectionMissingError } from './secured/read.js' +// Thrown when an Access Filter that scopes by a relation expands into itself, +// directly or through another list's filter. Loud rather than truncated: a +// truncated Access Filter is a widened read (#1147). +export { AccessFilterRecursionError, ACCESS_FILTER_MAX_DEPTH } from './secured/read.js' +export type { + SecuredQuery, + OrderBy, + OrderDirection, + RelationCondition, + ScalarOperators, + Where, + WhereCondition, + WhereValue, +} from './secured/read.js' // Naming utilities (documented public helpers; used for URLs) export { getUrlKey, getListKeyFromUrl, resolveListKeyFromUrl } from './lib/case-utils.js' @@ -185,6 +197,12 @@ export { InvalidFieldAccessResultError } from './access/index.js' // so it is refused loudly rather than silently treated as a full allow. export { InvalidCreateAccessResultError } from './access/index.js' +// Thrown by `mergeFilters` when an access rule returns a filter carrying an +// `undefined` condition — the shape `({ session }) => ({ authorId: +// session?.userId })` yields for an anonymous caller. Dropping it would widen +// the read to every row, so it is refused (see #1147, ADR-0022, ADR-0055). +export { UndefinedAccessFilterError } from './access/index.js' + // Thrown by a read when a caller-supplied `where` filters on a relation whose // related list denies operation-level `query` access outright (see #916 and // ADR-0022). Distinct from `ValidationError` for the same reason as @@ -294,7 +312,6 @@ export { collectFilterSpecs, buildListFilterWhere, collectFilterSuggestions, - RELATIONSHIP_COUNT_FILTER_KEY, } from './filter/index.js' export type { FilterOperator, @@ -303,27 +320,15 @@ export type { FilterSpec, FilterValueSource, FilterFieldSuggestion, - RelationshipCountFilterMarker, FilterAccessArgs, } from './filter/index.js' // Access-scoped to-many relationship counts for the admin list view (#732): -// build the filtered `_count` select for count cells/sort, and resolve the -// count Filter spec's markers into `{ id: { in } }` — all through the secured -// context, so counts never include related rows the session cannot read. +// the filtered `_count` select for count cells, with each related list's +// `query` access folded in, so counts never include rows the session cannot +// read. The count-filter resolver is gone with the vocabulary (ADR-0055): a +// count comparison shrinks to presence, which the engine lowers itself. export { buildRelationshipCountSelect, - resolveRelationshipCountFilters, isToManyRelationshipField, } from './access/relationship-count.js' - -// To-one relationship label filter helpers for the admin list view (#749). -// `resolveRelationshipLabelFilters` is now a pass-through: the engine itself -// scopes every relation filter in `where` (`buildAccessScopedWhere`, #916), -// including the `{ is: {...} } }` shape a label filter produces, so this no -// longer needs its own access fold. Kept exported, unchanged in shape, for -// API compatibility — see `relationship-label-filter.ts`'s doc comment. -export { - resolveRelationshipLabelFilters, - isToOneRelationshipField, -} from './access/relationship-label-filter.js' diff --git a/packages/core/src/secured/lower.ts b/packages/core/src/secured/lower.ts new file mode 100644 index 000000000..7394834d1 --- /dev/null +++ b/packages/core/src/secured/lower.ts @@ -0,0 +1,176 @@ +// Lowering a resolved Where plan onto Prisma's predicate lambda — the one +// place the vocabulary meets the ORM (ADR-0055). + +import type { AnyExpression, OrderByItem } from '@prisma/orm-postgres/relational-core' +import type { OrderPlan, ScalarStep, WherePlan } from './vocabulary.js' +import { unsupportedOperator, unqueryableKey } from './vocabulary.js' + +/** + * The comparison methods a column carries on Prisma's model accessor. Each is + * optional because the ORM gates them by the column's codec traits: `ilike` + * reaches a textual column only, and the ordering comparisons an orderable + * one, so an absent member is the ORM saying this operator does not apply to + * this column. + */ +interface ComparisonMember { + eq(value: unknown): AnyExpression + neq(value: unknown): AnyExpression + lt(value: unknown): AnyExpression + lte(value: unknown): AnyExpression + gt(value: unknown): AnyExpression + gte(value: unknown): AnyExpression + in(values: readonly unknown[]): AnyExpression + notIn(values: readonly unknown[]): AnyExpression + isNull(): AnyExpression + isNotNull(): AnyExpression + ilike(pattern: string): AnyExpression + asc(): OrderByItem + desc(): OrderByItem +} + +type RelationPredicate = (model: PredicateAccessor) => AnyExpression + +interface RelationMember { + some(predicate: RelationPredicate): AnyExpression + every(predicate: RelationPredicate): AnyExpression + none(predicate: RelationPredicate): AnyExpression +} + +type AccessorMember = Partial + +/** Prisma's model accessor, as the lowering drives it. */ +export interface PredicateAccessor { + [member: string]: AccessorMember | undefined +} + +/** + * The combinators the lowering builds `AND` and `OR` from. Prisma renders an + * empty `AND` as `TRUE` and an empty `OR` as `FALSE`, which is where the two + * constants come from. + */ +export interface WhereCombinators { + and(...exprs: AnyExpression[]): AnyExpression + or(...exprs: AnyExpression[]): AnyExpression + all(): AnyExpression +} + +let pending: Promise | undefined + +/** + * Load Prisma's expression combinators. Imported lazily so the package root + * keeps its static module graph free of `@prisma/orm-postgres`; a secured read + * cannot run without the ORM anyway, and the terminals that lower a predicate + * are already async. + */ +export function whereCombinators(): Promise { + pending ??= import('@prisma/orm-postgres/orm-client').then(({ and, or, all }) => ({ + and, + or, + all, + })) + return pending +} + +function memberOf(accessor: PredicateAccessor, listName: string, name: string): AccessorMember { + const member = accessor[name] + if (member === undefined) throw unqueryableKey(listName, name) + return member +} + +function stepExpression( + step: ScalarStep, + member: AccessorMember, + listName: string, + column: string, +): AnyExpression { + const refuse = (operator: string): never => { + throw unsupportedOperator(listName, column, operator) + } + switch (step.op) { + case 'eq': + return member.eq?.(step.value) ?? refuse('equals') + case 'neq': + return member.neq?.(step.value) ?? refuse('not') + case 'lt': + case 'lte': + case 'gt': + case 'gte': + return member[step.op]?.(step.value) ?? refuse(step.op) + case 'in': + return member.in?.(step.values) ?? refuse('in') + case 'notIn': + return member.notIn?.(step.values) ?? refuse('notIn') + case 'contains': + return member.ilike?.(step.pattern) ?? refuse('contains') + case 'isNull': + return member.isNull?.() ?? refuse('equals') + case 'isNotNull': + return member.isNotNull?.() ?? refuse('not') + } +} + +function combine( + nodes: readonly AnyExpression[], + empty: () => AnyExpression, + join: (...exprs: AnyExpression[]) => AnyExpression, +): AnyExpression { + if (nodes.length === 0) return empty() + if (nodes.length === 1) return nodes[0] + return join(...nodes) +} + +/** + * Build the ORM expression for a resolved plan. Synchronous by construction — + * every access decision was made during resolution — which is what lets a + * relation quantifier's predicate be a plain lambda. + */ +export function lowerWhere( + plan: WherePlan, + accessor: PredicateAccessor, + ops: WhereCombinators, +): AnyExpression { + switch (plan.kind) { + case 'true': + return ops.all() + case 'false': + return ops.or() + case 'and': + return combine( + plan.nodes.map((node) => lowerWhere(node, accessor, ops)), + () => ops.all(), + ops.and, + ) + case 'or': + return combine( + plan.nodes.map((node) => lowerWhere(node, accessor, ops)), + () => ops.or(), + ops.or, + ) + case 'not': + return lowerWhere(plan.node, accessor, ops).not() + case 'scalar': { + const member = memberOf(accessor, plan.listName, plan.column) + return combine( + plan.steps.map((step) => stepExpression(step, member, plan.listName, plan.column)), + () => ops.all(), + ops.and, + ) + } + case 'relation': { + const member = memberOf(accessor, plan.listName, plan.relation) + const quantifier = member[plan.quantifier] + if (quantifier === undefined) { + throw unsupportedOperator(plan.listName, plan.relation, plan.quantifier) + } + return quantifier((related) => lowerWhere(plan.node, related, ops)) + } + } +} + +/** Build one `ORDER BY` item for a resolved sort. */ +export function lowerOrder(plan: OrderPlan, accessor: PredicateAccessor): OrderByItem { + const member = memberOf(accessor, plan.listName, plan.column) + const item = plan.direction === 'asc' ? member.asc?.() : member.desc?.() + if (item === undefined) throw unsupportedOperator(plan.listName, plan.column, plan.direction) + return item +} diff --git a/packages/core/src/secured/operators.ts b/packages/core/src/secured/operators.ts new file mode 100644 index 000000000..943154a8a --- /dev/null +++ b/packages/core/src/secured/operators.ts @@ -0,0 +1,23 @@ +// The closed operator set of the Where vocabulary (ADR-0055). A leaf module so +// both the vocabulary itself and the read-path key validation can name it +// without importing each other. + +export const SCALAR_OPERATORS = [ + 'equals', + 'not', + 'in', + 'notIn', + 'lt', + 'lte', + 'gt', + 'gte', + 'contains', +] as const + +export const RELATION_QUANTIFIERS = ['some', 'every', 'none'] as const + +export type ScalarOperator = (typeof SCALAR_OPERATORS)[number] +export type RelationQuantifier = (typeof RELATION_QUANTIFIERS)[number] + +export const SCALAR_OPERATOR_SET: ReadonlySet = new Set(SCALAR_OPERATORS) +export const RELATION_QUANTIFIER_SET: ReadonlySet = new Set(RELATION_QUANTIFIERS) diff --git a/packages/core/src/secured/read.test.ts b/packages/core/src/secured/read.test.ts index df142abb1..fff715eb6 100644 --- a/packages/core/src/secured/read.test.ts +++ b/packages/core/src/secured/read.test.ts @@ -1,11 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' import type { OpenSaasConfig } from '../config/types.js' import type { Session } from '../access/types.js' -import { checkbox, relationship, text } from '../fields/index.js' +import { checkbox, integer, relationship, text } from '../fields/index.js' import { withOrigin } from '../origin.js' import { createTestDatabase, type TestDatabase } from '../testing/context.js' import { createPlanRecorder } from '../testing/plans.js' -import { lowerPredicate, UnsupportedPredicateError } from './read.js' +import { ValidationError } from '../hooks/index.js' +import { AccessFilterRecursionError, type Where } from './vocabulary.js' const BOOT = 120_000 @@ -25,6 +26,10 @@ const blogConfig: OpenSaasConfig = { fields: { handle: text({ validation: { isRequired: true } }), posts: relationship({ ref: 'Post.author', many: true }), + secrets: relationship({ ref: 'Secret.owner', many: true }), + // A relationship the session may not read: `orderBy` must refuse it + // with the message an undeclared key gets, not one that confirms it. + hidden: relationship({ ref: 'Secret', access: { read: () => false } }), }, access: { operation: { query: () => true } }, }, @@ -32,6 +37,7 @@ const blogConfig: OpenSaasConfig = { fields: { title: text({ validation: { isRequired: true } }), published: checkbox({ defaultValue: false }), + views: integer({ defaultValue: 0 }), editorNotes: text({ access: { read: () => false } }), author: relationship({ ref: 'User.posts' }), }, @@ -47,6 +53,14 @@ const blogConfig: OpenSaasConfig = { Draft: { fields: { title: text({ validation: { isRequired: true } }) }, }, + // Declares no rule, so `query` is denied by default — the related list a + // relation quantifier has to read as the empty set. + Secret: { + fields: { + code: text({ validation: { isRequired: true } }), + owner: relationship({ ref: 'User.secrets' }), + }, + }, Empty: { fields: { title: text({ validation: { isRequired: true } }) }, access: { operation: { query: () => true } }, @@ -58,6 +72,33 @@ const blogConfig: OpenSaasConfig = { }, access: { operation: { query: ({ session }) => ({ owner: session?.userId }) } }, }, + // An Access Filter that scopes by a relation on its own list: expanding it + // re-enters the same filter, so it has no fixed point. + SelfRef: { + fields: { + title: text({ validation: { isRequired: true } }), + parent: relationship({ ref: 'SelfRef.children' }), + children: relationship({ ref: 'SelfRef.parent', many: true }), + }, + access: { + operation: { query: () => ({ parent: { some: { title: { equals: 'root' } } } }) }, + }, + }, + // The same shape spread across two lists, which no single-list guard sees. + Left: { + fields: { + title: text({ validation: { isRequired: true } }), + rights: relationship({ ref: 'Right.left', many: true }), + }, + access: { operation: { query: () => ({ rights: { some: {} } }) } }, + }, + Right: { + fields: { + title: text({ validation: { isRequired: true } }), + left: relationship({ ref: 'Left.rights' }), + }, + access: { operation: { query: () => ({ left: { some: {} } }) } }, + }, }, } @@ -104,11 +145,22 @@ async function seedBlog(): Promise { await seed('Post', { title: "ada's published", published: true, + views: 10, author: ada.userId, editorNotes: 'secret', }) - await seed('Post', { title: "ada's draft", published: false, author: ada.userId }) - await seed('Post', { title: "bob's published", published: true, author: bob.userId }) + await seed('Post', { + title: "ada's draft", + published: false, + views: 2, + author: ada.userId, + }) + await seed('Post', { + title: "bob's published", + published: true, + views: 5, + author: bob.userId, + }) await seed('Draft', { title: 'nobody may read this' }) } @@ -326,7 +378,7 @@ describe('a composed read is an immutable value', () => { async () => { const query = database.context(ada).db.Post.where({ published: true }) - expect(Object.keys(query).sort()).toEqual(['all', 'first', 'where']) + expect(Object.keys(query).sort()).toEqual(['all', 'first', 'orderBy', 'where']) for (const member of ['state', 'ctx', 'modelName', 'registry', 'tableName']) { expect(Reflect.get(query, member)).toBeUndefined() } @@ -370,68 +422,419 @@ describe('Field Visibility', () => { ) }) -describe('a predicate the engine cannot lower is refused', () => { +describe('the Where vocabulary', () => { + beforeEach(async () => { + await seedBlog() + recorder.clear() + }) + + const owned = (predicate: Where) => database.context(ada).sudo().db.Post.where(predicate) + test( - 'an operator outside the vocabulary throws rather than widening the read', + 'every scalar operator lowers and returns the rows it names', async () => { - await expect( - database - .context(ada) - // @ts-expect-error -- the vocabulary refuses this at compile time too - .db.Post.where({ title: { contains: 'ada' } }) - .all(), - ).rejects.toBeInstanceOf(UnsupportedPredicateError) + expect(titles(await owned({ title: { equals: "ada's draft" } }).all())).toEqual([ + "ada's draft", + ]) + expect(titles(await owned({ title: { not: "ada's draft" } }).all())).toEqual([ + "ada's published", + "bob's published", + ]) + expect(titles(await owned({ views: { in: [2, 5] } }).all())).toEqual([ + "ada's draft", + "bob's published", + ]) + expect(titles(await owned({ views: { notIn: [2, 5] } }).all())).toEqual(["ada's published"]) + expect(titles(await owned({ views: { lt: 5 } }).all())).toEqual(["ada's draft"]) + expect(titles(await owned({ views: { lte: 5 } }).all())).toEqual([ + "ada's draft", + "bob's published", + ]) + expect(titles(await owned({ views: { gt: 5 } }).all())).toEqual(["ada's published"]) + expect(titles(await owned({ views: { gte: 5 } }).all())).toEqual([ + "ada's published", + "bob's published", + ]) + expect(titles(await owned({ title: { contains: 'draft' } }).all())).toEqual(["ada's draft"]) + }, + BOOT, + ) + + test( + 'operators on one column are ANDed', + async () => { + expect(titles(await owned({ views: { gte: 2, lt: 10 } }).all())).toEqual([ + "ada's draft", + "bob's published", + ]) + }, + BOOT, + ) + + test( + 'contains is case-insensitive and matches a literal per-cent sign', + async () => { + await seed('Post', { title: '50% off, ADA', published: true, views: 1, author: ada.userId }) + + expect(titles(await owned({ title: { contains: 'ada' } }).all())).toEqual([ + '50% off, ADA', + "ada's draft", + "ada's published", + ]) + // `%` is escaped rather than bound as a wildcard, so this matches the one + // title that carries the character itself. + expect(titles(await owned({ title: { contains: '50%' } }).all())).toEqual(['50% off, ADA']) + expect(titles(await owned({ title: { contains: '%' } }).all())).toEqual(['50% off, ADA']) + }, + BOOT, + ) + + test( + 'equals: null is IS NULL and not: null is IS NOT NULL', + async () => { + await seed('Post', { title: 'unowned', published: true, views: 0 }) + + expect(titles(await owned({ author: { some: {} } }).all())).toEqual([ + "ada's draft", + "ada's published", + "bob's published", + ]) + expect(titles(await owned({ authorId: { equals: null } }).all())).toEqual(['unowned']) + expect(titles(await owned({ authorId: { not: null } }).all())).toEqual([ + "ada's draft", + "ada's published", + "bob's published", + ]) + }, + BOOT, + ) + + test( + 'AND, OR and NOT combine predicates', + async () => { + expect( + titles( + await owned({ + OR: [{ title: { contains: 'draft' } }, { views: { equals: 5 } }], + }).all(), + ), + ).toEqual(["ada's draft", "bob's published"]) + + expect( + titles(await owned({ AND: [{ published: true }, { views: { gt: 5 } }] }).all()), + ).toEqual(["ada's published"]) + + expect(titles(await owned({ NOT: { published: true } }).all())).toEqual(["ada's draft"]) + }, + BOOT, + ) + + test( + 'a relation quantifier lowers to an EXISTS over the related list', + async () => { + const handles = async (predicate: Where): Promise => + (await database.context(ada).db.User.where(predicate).all()).map((row) => row.handle).sort() + + expect(await handles({ posts: { some: { published: false } } })).toEqual(['ada']) + expect(await handles({ posts: { none: { published: false } } })).toEqual(['bob']) + expect(await handles({ posts: { some: {} } })).toEqual(['ada']) + }, + BOOT, + ) + + test( + 'a relation predicate ANDs the related list access filter inside the EXISTS', + async () => { + const handles = async (predicate: Where): Promise => + (await database.context(null).db.User.where(predicate).all()) + .map((row) => row.handle) + .sort() + + // Anonymously, `Post` scopes to published rows, so the draft is not + // visible to the quantifier at all: `some` cannot find it, and `every` + // is measured over the visible rows only. + expect(await handles({ posts: { some: { published: false } } })).toEqual([]) + expect(await handles({ posts: { some: {} } })).toEqual(['ada', 'bob']) + expect(await handles({ posts: { none: { published: true } } })).toEqual([]) + }, + BOOT, + ) + + test( + 'every asks whether every VISIBLE row matches, so an invisible row decides nothing', + async () => { + const handles = async (session: Session | null, predicate: Where): Promise => + (await database.context(session).db.User.where(predicate).all()) + .map((row) => row.handle) + .sort() + + // ada owns a draft; bob does not. Anonymously neither draft nor any + // unpublished row is visible, so both users' visible posts are all + // published and both satisfy `every`. If the access filter were ANDed + // into the quantifier body instead, ada would drop out here — a + // positive signal about a row this caller may not see (#1123 story 10). + expect(await handles(null, { posts: { every: { published: true } } })).toEqual(['ada', 'bob']) + expect(await handles(null, { posts: { every: { published: false } } })).toEqual([]) + + // ada's own session sees her draft, so it does decide her membership; + // bob's posts are invisible to her, which makes his `every` vacuously + // true rather than false. + expect(await handles(ada, { posts: { every: { published: true } } })).toEqual(['bob']) + }, + BOOT, + ) + + test( + 'a related list the session cannot query is the empty set', + async () => { + const context = database.context(ada) + await seed('Secret', { code: 'shh', owner: ada.userId }) + + // `Secret` denies `query`, so `some` is false and `none`/`every` are + // true — the parent rows are never distinguished by a list the session + // cannot see. + expect(await context.db.User.where({ secrets: { some: {} } }).all()).toEqual([]) + expect( + (await context.db.User.where({ secrets: { none: {} } }).all()).map((row) => row.handle), + ).toEqual(['ada', 'bob']) + expect( + (await context.db.User.where({ secrets: { every: {} } }).all()).map((row) => row.handle), + ).toEqual(['ada', 'bob']) + }, + BOOT, + ) + + test( + "orderBy sorts by the list's own columns", + async () => { + const context = database.context(ada).sudo() + expect(titles(await context.db.Post.orderBy({ views: 'asc' }).all())).toEqual([ + "ada's draft", + "ada's published", + "bob's published", + ]) + expect( + (await context.db.Post.orderBy({ views: 'desc' }).all()).map((row) => row.views), + ).toEqual([10, 5, 2]) + }, + BOOT, + ) + + test( + 'orderBy is scalar-only: a relation is refused', + async () => { + await expect(database.context(ada).db.Post.orderBy({ author: 'asc' }).all()).rejects.toThrow( + /scalar columns only/, + ) }, BOOT, ) }) -describe('every terminal runs inside the engine origin', () => { +describe("the engine's own recursion is bounded", () => { + test( + 'an Access Filter that expands into itself refuses the read and names the lists', + async () => { + await expect(database.context(ada).db.SelfRef.all()).rejects.toThrow( + /Access Filter on "SelfRef" is cyclic.*SelfRef → SelfRef/s, + ) + expect(recorder.plans).toEqual([]) + }, + BOOT, + ) + + test( + 'two lists whose Access Filters reference each other are refused the same way', + async () => { + await expect(database.context(ada).db.Left.all()).rejects.toThrow( + /is cyclic.*Left → Right → Left/s, + ) + await expect(database.context(ada).db.Right.all()).rejects.toThrow( + /is cyclic.*Right → Left → Right/s, + ) + expect(recorder.plans).toEqual([]) + }, + BOOT, + ) + + test( + 'the refusal is loud, not a truncated filter: no rows come back either way', + async () => { + await seed('SelfRef', { title: 'root' }) + // A truncated Access Filter would be a widened read, so the failure has + // to be an error rather than a result set of any size. + await expect(database.context(ada).db.SelfRef.all()).rejects.toThrow( + AccessFilterRecursionError, + ) + }, + BOOT, + ) +}) + +describe('the vocabulary is closed, and refusing is not an oracle', () => { beforeEach(async () => { await seedBlog() recorder.clear() }) + const message = async (run: Promise): Promise => { + try { + await run + } catch (error) { + if (error instanceof ValidationError) return error.errors.join(' ') + throw error + } + throw new Error('the read was expected to be refused') + } + test( - 'all() and first() compile their plans under the engine stamp', + 'an unknown key names the list and the key', + async () => { + await expect(database.context(ada).db.Post.where({ nope: 'x' }).all()).rejects.toThrow( + /Cannot query "Post" — "nope"/, + ) + }, + BOOT, + ) + + test( + 'an unknown operator names the list and the key', + async () => { + await expect( + database + .context(ada) + .db.Post.where({ title: { startsWith: 'ada' } }) + .all(), + ).rejects.toThrow(/Cannot query "Post" — "title" was given "startsWith"/) + }, + BOOT, + ) + + test( + 'sudo is refused identically: an unknown operator is a bug, not a permission', + async () => { + const context = database.context(ada).sudo() + await expect(context.db.Post.where({ nope: 'x' }).all()).rejects.toThrow( + /Cannot query "Post" — "nope"/, + ) + await expect(context.db.Post.where({ title: { mode: 'insensitive' } }).all()).rejects.toThrow( + /is not part of the Where vocabulary/, + ) + expect(recorder.plans).toEqual([]) + }, + BOOT, + ) + + test( + 'a read-denied field is refused identically to one the list does not declare', async () => { const context = database.context(ada) - await context.db.Post.all() - await context.db.Post.where({ published: true }).first() + const denied = await message(context.db.Post.where({ editorNotes: 'secret' }).all()) + const absent = await message(context.db.Post.where({ nope: 'secret' }).all()) - expect(recorder.plans).toHaveLength(2) - expect(recorder.plans.map((plan) => plan.origin)).toEqual(['engine', 'engine']) + expect(denied.replace('editorNotes', 'nope')).toBe(absent) + }, + BOOT, + ) + + test( + 'orderBy refuses a read-denied relationship exactly as it refuses an absent key', + async () => { + const context = database.context(ada) + const denied = await message(context.db.User.orderBy({ hidden: 'asc' }).all()) + const absent = await message(context.db.User.orderBy({ nope: 'asc' }).all()) + + expect(denied.replace('hidden', 'nope')).toBe(absent) + expect(denied).not.toMatch(/scalar columns only/) + }, + BOOT, + ) + + test( + 'a nested predicate is validated whether or not the related list is queryable', + async () => { + const context = database.context(ada) + // `Secret` denies `query` and `Post` does not; refusing the nested key + // in only one of them would make the refusal an existence oracle for + // the related list's own access. + const denied = await message(context.db.User.where({ secrets: { some: { nope: 1 } } }).all()) + const queryable = await message(context.db.User.where({ posts: { some: { nope: 1 } } }).all()) + + expect(denied).toBe('Cannot query "Secret" — "nope" is not a queryable field of this list.') + expect(denied.replace('Secret', 'Post')).toBe(queryable) + }, + BOOT, + ) + + test( + 'a denied caller never reaches key validation at all', + async () => { + // `Draft` denies `query` outright, so the Silent failure comes first: an + // undeclared key must not tell an unauthorised caller anything. + expect(await database.context(ada).db.Draft.where({ nope: 'x' }).all()).toEqual([]) + expect(recorder.plans).toEqual([]) }, BOOT, ) }) -// The two spellings behave oppositely today: `lowerPredicate` skips an -// `undefined` condition (Prisma's `undefined`-means-omitted semantics) and -// refuses the explicit `{ equals: undefined }`. #1147 makes the lowering total; -// these tests are what makes that a visible change rather than a silent one. -describe('an undefined condition, pending the total Where vocabulary (#1147)', () => { - test('a bare `undefined` is skipped, so the entry constrains nothing', () => { - expect(lowerPredicate('Post', { authorId: undefined })).toEqual({}) - expect(lowerPredicate('Post', { published: true, authorId: undefined })).toEqual({ - published: true, - }) - }) +// The lowering is total: an `undefined` condition is refused on BOTH spellings. +// Before #1147 the bare spelling was skipped, which silently widened the read — +// and because the Access Filter is lowered through the same seam, the idiomatic +// `({ session }) => ({ owner: session?.userId })` matched every row for an +// anonymous caller. These tests pinned that behaviour; they now pin its refusal. +describe('the lowering is total', () => { + test( + 'a bare `undefined` is refused rather than dropped', + async () => { + await expect(database.context(ada).db.Post.where({ title: undefined }).all()).rejects.toThrow( + /is undefined/, + ) + }, + BOOT, + ) - test('the same rule spelled `{ equals: undefined }` is refused', () => { - expect(() => lowerPredicate('Post', { authorId: { equals: undefined } })).toThrow( - UnsupportedPredicateError, - ) - }) + test( + 'the same rule spelled `{ equals: undefined }` is refused identically', + async () => { + await expect( + database + .context(ada) + .db.Post.where({ title: { equals: undefined } }) + .all(), + ).rejects.toThrow(/is undefined/) + }, + BOOT, + ) test( - 'an Access Filter that yields undefined therefore widens the read to every row', + 'an Access Filter that yields undefined refuses the read instead of widening it', async () => { await seed('Widened', { title: "ada's", owner: 'ada' }) await seed('Widened', { title: "bob's", owner: 'bob' }) - const anonymous = await database.context(null).db.Widened.all() - expect(titles(anonymous)).toEqual(["ada's", "bob's"]) + await expect(database.context(null).db.Widened.all()).rejects.toThrow(/is undefined/) + expect(titles(await database.context({ userId: 'ada' }).db.Widened.all())).toEqual(["ada's"]) + }, + BOOT, + ) +}) + +describe('every terminal runs inside the engine origin', () => { + beforeEach(async () => { + await seedBlog() + recorder.clear() + }) + + test( + 'all() and first() compile their plans under the engine stamp', + async () => { + const context = database.context(ada) + await context.db.Post.all() + await context.db.Post.where({ published: true }).first() + + expect(recorder.plans).toHaveLength(2) + expect(recorder.plans.map((plan) => plan.origin)).toEqual(['engine', 'engine']) }, BOOT, ) @@ -444,7 +847,17 @@ describe('context.db is keyed by the PascalCase list name', () => { const context = database.context(null) expect(typeof context.db.Post.all).toBe('function') expect(Reflect.get(context.db, 'post')).toBeUndefined() - expect(Object.keys(context.db).sort()).toEqual(['Draft', 'Empty', 'Post', 'User', 'Widened']) + expect(Object.keys(context.db).sort()).toEqual([ + 'Draft', + 'Empty', + 'Left', + 'Post', + 'Right', + 'Secret', + 'SelfRef', + 'User', + 'Widened', + ]) }, BOOT, ) diff --git a/packages/core/src/secured/read.ts b/packages/core/src/secured/read.ts index 5adce4c84..ec8355879 100644 --- a/packages/core/src/secured/read.ts +++ b/packages/core/src/secured/read.ts @@ -1,37 +1,45 @@ // The secured read surface: `context.db.` as an opaque wrapper over a -// Prisma 8 collection, its `where` composition, and the `all()`/`first()` -// terminals the engine owns. See ADR-0041, ADR-0044, ADR-0046 and ADR-0058. +// Prisma 8 collection, its `where`/`orderBy` composition, and the +// `all()`/`first()` terminals the engine owns. See ADR-0041, ADR-0044, +// ADR-0046, ADR-0055 and ADR-0058. +import type { AnyExpression, OrderByItem } from '@prisma/orm-postgres/relational-core' import type { OpenSaasConfig, ListConfig, TypeInfo } from '../config/types.js' import type { AccessContext, OrmClient, OrmRow, PrismaFilter, Session } from '../access/types.js' -import { - checkAccess, - filterReadableFields, - validateQueryKeys, - validateQueryFieldReadAccess, -} from '../access/index.js' +import { checkAccess, filterReadableFields } from '../access/index.js' import { withOrigin } from '../origin.js' - -/** A value a predicate compares a column against. */ -export type WhereValue = string | number | boolean | bigint | Date | null - -/** One column's condition: the value itself, or an explicit `equals`. */ -export type WhereCondition = WhereValue | { equals: WhereValue } - -/** - * A predicate over a list's own columns. - * - * Equality only. The closed Where vocabulary — `in`, `not`, the comparisons, - * `contains`, the logical combinators and the relation quantifiers — is - * ADR-0055's, and {@link lowerPredicate} refuses everything it does not yet - * lower rather than passing it to the ORM unscoped. - */ -export type Where = { [column: string]: WhereCondition } +import { + lowerOrder, + lowerWhere, + whereCombinators, + type PredicateAccessor, + type WhereCombinators, +} from './lower.js' +import { + resolveOrderBy, + resolveWhere, + type OrderBy, + type OrderPlan, + type ResolveContext, + type Where, + type WherePlan, +} from './vocabulary.js' + +export { AccessFilterRecursionError, ACCESS_FILTER_MAX_DEPTH } from './vocabulary.js' +export type { + OrderBy, + OrderDirection, + RelationCondition, + ScalarOperators, + Where, + WhereCondition, + WhereValue, +} from './vocabulary.js' /** * A composed read: an immutable value carrying the list, the predicates and - * nothing that can execute unscoped. `where` returns a new value; the - * terminals are the only way to reach the database. + * nothing that can execute unscoped. `where` and `orderBy` return a new value; + * the terminals are the only way to reach the database. * * Rows are untyped here for the same reason the rest of the engine's own view * is: the per-list shapes live in the generated bundle, which instantiates @@ -40,32 +48,14 @@ export type Where = { [column: string]: WhereCondition } export interface SecuredQuery { /** Narrow the read. Composes; nothing is enforced until a terminal runs. */ where(predicate: Where): SecuredQuery + /** Sort the read by the list's own scalar columns. */ + orderBy(order: OrderBy | readonly OrderBy[]): SecuredQuery /** Every row this session may see. `[]` when the read is denied. */ all(): Promise /** The first row this session may see, or `null` — denied or absent alike. */ first(): Promise } -/** - * Thrown when a predicate names an operator the engine does not lower. - * - * A predicate can only ever narrow, so an unrecognised operator is refused - * rather than dropped: dropping it would widen the read (ADR-0055). - */ -export class UnsupportedPredicateError extends Error { - constructor( - readonly listName: string, - readonly column: string, - readonly detail: string, - ) { - super( - `Cannot lower the predicate on "${listName}.${column}": ${detail}. The secured surface ` + - `takes an equality predicate — \`{ ${column}: value }\` or \`{ ${column}: { equals: value } }\`.`, - ) - this.name = 'UnsupportedPredicateError' - } -} - /** * Thrown when the ORM client carries no collection for a list the config * declares — a generation or wiring fault rather than an access denial, so it @@ -81,26 +71,22 @@ export class SecuredCollectionMissingError extends Error { } } -/** A filter list entry, as the collection's shorthand `where` takes it. */ -type FilterEntry = Record - /** * The part of a Prisma 8 collection the read path drives, structurally. - * `where` appends a filter entry — repeated calls are AND-combined by the ORM, + * `where` appends a predicate — repeated calls are AND-combined by the ORM, * which is what makes the Access Filter a second entry rather than a merge. */ interface ReadableCollection { - where(filter: FilterEntry): ReadableCollection + where(predicate: (model: PredicateAccessor) => AnyExpression): ReadableCollection + orderBy(selection: readonly ((model: PredicateAccessor) => OrderByItem)[]): ReadableCollection all(): PromiseLike first(): Promise } function isReadableCollection(value: unknown): value is ReadableCollection { if (typeof value !== 'object' || value === null) return false - const candidate: Record = Object.create(null) - for (const member of ['where', 'all', 'first']) { - candidate[member] = Reflect.get(value, member) - if (typeof candidate[member] !== 'function') return false + for (const member of ['where', 'orderBy', 'all', 'first']) { + if (typeof Reflect.get(value, member) !== 'function') return false } return true } @@ -111,58 +97,6 @@ function collectionFor(ormHandle: OrmClient, listName: string): ReadableCollecti return collection } -function isWhereValue(value: unknown): value is WhereValue { - return ( - value === null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'bigint' || - value instanceof Date - ) -} - -function lowerCondition(listName: string, column: string, condition: unknown): WhereValue { - if (isWhereValue(condition)) return condition - if (typeof condition !== 'object') { - throw new UnsupportedPredicateError(listName, column, 'the condition is not a value') - } - const keys = Object.keys(condition) - if (keys.length !== 1 || keys[0] !== 'equals') { - throw new UnsupportedPredicateError( - listName, - column, - `\`${keys.join(', ')}\` is not an operator the engine lowers yet`, - ) - } - const value: unknown = Reflect.get(condition, 'equals') - if (!isWhereValue(value)) { - throw new UnsupportedPredicateError(listName, column, '`equals` takes a scalar value') - } - return value -} - -/** - * Lower one predicate to a single filter entry. An operator the engine does - * not lower throws rather than being dropped, `sudo` included — the Access - * Filter passes through here too. - * - * Known limit: an `undefined` condition is skipped, matching Prisma's - * `undefined`-means-omitted semantics. A filter rule spelled - * `({ session }) => ({ authorId: session?.userId })` therefore lowers to `{}` - * for an anonymous caller and constrains nothing, while the explicit - * `{ equals: undefined }` spelling of the same rule is refused. Totality - * arrives with the closed Where vocabulary (#1147). - */ -export function lowerPredicate(listName: string, predicate: Record): FilterEntry { - const entry: FilterEntry = {} - for (const [column, condition] of Object.entries(predicate)) { - if (condition === undefined) continue - entry[column] = lowerCondition(listName, column, condition) - } - return entry -} - interface ReadBinding { listName: string listConfig: ListConfig @@ -173,46 +107,84 @@ interface ReadBinding { interface QueryState { readonly predicates: readonly Where[] + readonly orders: readonly OrderBy[] } -async function resolveAccessFilter( - binding: ReadBinding, - state: QueryState, -): Promise { - const { listName, listConfig, context, config } = binding - const session: Session | null = context.session - const lower = (): FilterEntry[] => - state.predicates.map((predicate) => lowerPredicate(listName, predicate)) +/** A resolved read: the predicates to AND, and the sort to apply. */ +interface ReadPlan { + readonly predicates: readonly WherePlan[] + readonly orders: readonly OrderPlan[] +} + +function resolveContext(binding: ReadBinding, secured: boolean): ResolveContext { + return { + listName: binding.listName, + listConfig: binding.listConfig, + config: binding.config, + session: binding.context.session, + context: binding.context, + checkFieldRead: secured, + applyRelationAccess: secured, + accessFilterPath: [], + } +} - if (context._isSudo) return lower() +/** + * Resolve the read: operation access first, then the vocabulary. + * + * The order matters. Resolution names the offending key, so running it before + * the access check would tell a caller with no access at all that a field + * exists and whether it is read-gated (#912, #915). A denied caller gets the + * Silent failure and never sees a validation error. + * + * `sudo` skips access but not the vocabulary: an unknown key or operator is a + * bug rather than a permission, and letting it through would widen the read + * with a flag on it (ADR-0022, ADR-0055). + */ +async function resolvePlan(binding: ReadBinding, state: QueryState): Promise { + const { listConfig, context } = binding + const session: Session | null = context.session + const secured = context._isSudo !== true + const ctx = resolveContext(binding, secured) - const access = await checkAccess(listConfig.access?.operation?.query, { session, context }) + const access = secured + ? await checkAccess(listConfig.access?.operation?.query, { session, context }) + : true if (access === false) return null - // Runs only once the caller is known to have SOME access to the list: these - // errors name the offending key, so running them first would tell a caller - // with no access at all that a field exists and whether it is read-gated - // (#912, #915). + const predicates: WherePlan[] = [] for (const predicate of state.predicates) { - validateQueryKeys({ where: predicate, listConfig, listName, config, isSudo: false }) - await validateQueryFieldReadAccess({ - where: predicate, - listConfig, - listName, - session, - context, - isSudo: false, - }) + predicates.push(await resolveWhere(predicate, ctx)) + } + const orders = await resolveOrderBy(state.orders, ctx) + + if (access !== true) { + const filter: PrismaFilter = access + // The Access Filter is trusted config, so its keys are not read-gated — + // but it is lowered through the same total seam, which is what stops a + // rule that resolved to `undefined` from matching every row. + predicates.push( + await resolveWhere(filter, { + ...resolveContext(binding, true), + checkFieldRead: false, + accessFilterPath: [binding.listName], + }), + ) } - if (access === true) return lower() - const filter: PrismaFilter = access - return [...lower(), lowerPredicate(listName, filter)] + return { predicates, orders } } -function scope(binding: ReadBinding, entries: readonly FilterEntry[]): ReadableCollection { +function scope(binding: ReadBinding, plan: ReadPlan, ops: WhereCombinators): ReadableCollection { let collection = collectionFor(binding.ormHandle, binding.listName) - for (const entry of entries) collection = collection.where(entry) + for (const predicate of plan.predicates) { + collection = collection.where((model) => lowerWhere(predicate, model, ops)) + } + if (plan.orders.length > 0) { + collection = collection.orderBy( + plan.orders.map((order) => (model: PredicateAccessor) => lowerOrder(order, model)), + ) + } return collection } @@ -229,24 +201,35 @@ function visible(binding: ReadBinding, row: OrmRow): Promise { } async function runAll(binding: ReadBinding, state: QueryState): Promise { - const entries = await resolveAccessFilter(binding, state) - if (entries === null) return [] - const collection = scope(binding, entries) + const plan = await resolvePlan(binding, state) + if (plan === null) return [] + const collection = scope(binding, plan, await whereCombinators()) const rows = await withOrigin('engine', () => collection.all()) return await Promise.all(rows.map((row) => visible(binding, row))) } async function runFirst(binding: ReadBinding, state: QueryState): Promise { - const entries = await resolveAccessFilter(binding, state) - if (entries === null) return null - const collection = scope(binding, entries) + const plan = await resolvePlan(binding, state) + if (plan === null) return null + const collection = scope(binding, plan, await whereCombinators()) const row = await withOrigin('engine', () => collection.first()) return row === null ? null : await visible(binding, row) } +function isOrderList(order: OrderBy | readonly OrderBy[]): order is readonly OrderBy[] { + return Array.isArray(order) +} + +function orderList(order: OrderBy | readonly OrderBy[]): readonly OrderBy[] { + return isOrderList(order) ? order : [order] +} + function query(binding: ReadBinding, state: QueryState): SecuredQuery { return { - where: (predicate: Where) => query(binding, { predicates: [...state.predicates, predicate] }), + where: (predicate: Where) => + query(binding, { ...state, predicates: [...state.predicates, predicate] }), + orderBy: (order: OrderBy | readonly OrderBy[]) => + query(binding, { ...state, orders: [...state.orders, ...orderList(order)] }), all: () => runAll(binding, state), first: () => runFirst(binding, state), } @@ -259,5 +242,5 @@ function query(binding: ReadBinding, state: QueryState): SecuredQuery { * or its type (ADR-0041, ADR-0057). */ export function createSecuredRead(binding: ReadBinding): SecuredQuery { - return query(binding, { predicates: [] }) + return query(binding, { predicates: [], orders: [] }) } diff --git a/packages/core/src/secured/vocabulary.ts b/packages/core/src/secured/vocabulary.ts new file mode 100644 index 000000000..52d7b543d --- /dev/null +++ b/packages/core/src/secured/vocabulary.ts @@ -0,0 +1,533 @@ +// The Where vocabulary (ADR-0055): the closed predicate grammar the secured +// surface accepts, and the resolution pass that turns a vocabulary value into +// a plan the lowering can build without asking another question. Nothing here +// imports the ORM — the same property that keeps `../filter/` unit-testable. + +import type { ListConfig, OpenSaasConfig, TypeInfo } from '../config/types.js' +import type { AccessContext, PrismaFilter, Session } from '../access/types.js' +import { checkAccess, getRelatedListConfig } from '../access/engine.js' +import { isFieldReadableForPredicate } from '../access/field-access.js' +import { resolveQueryField } from '../access/query-validation.js' +import { ValidationError } from '../hooks/index.js' +import { + RELATION_QUANTIFIERS, + RELATION_QUANTIFIER_SET, + SCALAR_OPERATORS, + SCALAR_OPERATOR_SET, + type RelationQuantifier, +} from './operators.js' + +export { RELATION_QUANTIFIERS, SCALAR_OPERATORS } from './operators.js' +export type { RelationQuantifier, ScalarOperator } from './operators.js' + +/** A value a predicate compares a column against. */ +export type WhereValue = string | number | boolean | bigint | Date | null + +/** + * The scalar operators, in the spelling a predicate uses. Several may appear + * on one column and are ANDed. `equals: null` is `IS NULL` and `not: null` is + * `IS NOT NULL`; `contains` is case-insensitive and matches its value + * literally, per-cent signs and underscores included. + */ +export interface ScalarOperators { + equals?: WhereValue + not?: WhereValue + in?: readonly WhereValue[] + notIn?: readonly WhereValue[] + lt?: WhereValue + lte?: WhereValue + gt?: WhereValue + gte?: WhereValue + contains?: string +} + +/** One column's condition: a bare value (equality) or the operator object. */ +export type WhereCondition = WhereValue | ScalarOperators + +/** A relation's condition. Every relation takes the same three quantifiers. */ +export interface RelationCondition { + some?: Where + every?: Where + none?: Where +} + +/** + * A predicate over one list, as the engine sees it. Keys are the list's own + * fields, `AND`/`OR`/`NOT`, or a relation carrying its quantifiers; several + * keys in one object are ANDed. + * + * Loose here on purpose: the per-list shapes live in the generated bundle, + * which instantiates `ListPredicate` from the emitted contract (ADR-0052). + */ +export interface Where { + [key: string]: WhereCondition | RelationCondition | Where | readonly Where[] | undefined +} + +/** A sort direction, as `orderBy` spells it. */ +export type OrderDirection = 'asc' | 'desc' + +/** What `orderBy` takes: the list's own scalar columns and a direction. */ +export interface OrderBy { + [column: string]: OrderDirection +} + +const LOGICAL_OPERATORS: ReadonlySet = new Set(['AND', 'OR', 'NOT']) + +/** + * A key a caller may not query, for either of the two reasons that must be + * indistinguishable: the list does not declare it, or the session cannot read + * it. One message for both, so the refusal is not an existence oracle + * (ADR-0031). + */ +export function unqueryableKey(listName: string, key: string): ValidationError { + return new ValidationError([ + `Cannot query "${listName}" — "${key}" is not a queryable field of this list.`, + ]) +} + +export function unsupportedOperator( + listName: string, + key: string, + operator: string, +): ValidationError { + return new ValidationError([ + `Cannot query "${listName}" — "${key}" was given "${operator}", which is not part of the ` + + `Where vocabulary (${SCALAR_OPERATORS.join(', ')}; ${RELATION_QUANTIFIERS.join(', ')} on a ` + + `relation; AND, OR, NOT).`, + ]) +} + +function malformedCondition(listName: string, key: string, detail: string): ValidationError { + return new ValidationError([`Cannot query "${listName}" — the predicate on "${key}" ${detail}.`]) +} + +/** + * How many Access Filters deep the engine will expand before refusing. A + * relation key inside an Access Filter re-enters access resolution, so this + * recursion is the engine's own and ADR-0043's caller-facing depth cap does + * not bound it. Cycles are caught by name; this is the second bound, for an + * acyclic chain long enough that it is a configuration mistake rather than a + * design. Ten is well past any real ownership chain and far short of a stack + * the process cannot hold. + */ +export const ACCESS_FILTER_MAX_DEPTH = 10 + +/** + * An Access Filter that expands into itself, directly or through another + * list's filter. Refused loudly rather than truncated: a truncated Access + * Filter is a widened read, so failing closed is the only safe answer. + */ +export class AccessFilterRecursionError extends Error { + constructor( + readonly listPath: readonly string[], + reason: 'cycle' | 'depth', + ) { + const chain = listPath.join(' → ') + super( + reason === 'cycle' + ? `The Access Filter on "${listPath[listPath.length - 1]}" is cyclic: expanding it ` + + `re-enters the same list (${chain}). A relation key inside an Access Filter is ` + + `expanded into the related list's own filter, so a cycle has no fixed point and the ` + + `read is refused rather than resolved with a truncated filter. Scope the rule by a ` + + `column, or break the cycle by returning \`true\`/\`false\` on one side.` + : `The Access Filter on "${listPath[0]}" expands more than ${ACCESS_FILTER_MAX_DEPTH} ` + + `lists deep (${chain}). The read is refused rather than resolved with a truncated ` + + `filter; flatten the chain of relation-scoped access rules.`, + ) + this.name = 'AccessFilterRecursionError' + } +} + +/** + * An `undefined` condition is refused rather than dropped. Dropping it widens + * the read, and the Access Filter is lowered through this same seam: a rule + * spelled `({ session }) => ({ authorId: session?.userId })` would otherwise + * match every row for an anonymous caller, which is fail-open (ADR-0022, + * ADR-0055). + */ +function undefinedCondition(listName: string, key: string): ValidationError { + return new ValidationError([ + `Cannot query "${listName}" — the predicate on "${key}" is undefined. The Where vocabulary ` + + `is total: a predicate may only narrow, so a condition that resolved to undefined is ` + + `refused rather than dropped. An access rule that has nothing to scope by must return ` + + `\`false\` (deny) or \`true\` (allow) explicitly.`, + ]) +} + +/** One resolved comparison on one column. */ +export type ScalarStep = + | { op: 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte'; value: Exclude } + | { op: 'in' | 'notIn'; values: readonly WhereValue[] } + | { op: 'contains'; pattern: string } + | { op: 'isNull' | 'isNotNull' } + +/** + * A resolved predicate: every key checked, every relation's `query` access + * already decided. Building the ORM expression from one asks no further + * questions, which is what lets a relation quantifier's lambda stay + * synchronous. + */ +export type WherePlan = + | { kind: 'true' } + | { kind: 'false' } + | { kind: 'and'; nodes: readonly WherePlan[] } + | { kind: 'or'; nodes: readonly WherePlan[] } + | { kind: 'not'; node: WherePlan } + | { kind: 'scalar'; listName: string; column: string; steps: readonly ScalarStep[] } + | { + kind: 'relation' + listName: string + relation: string + relatedListName: string + quantifier: RelationQuantifier + node: WherePlan + } + +/** A resolved sort: one scalar column and a direction. */ +export interface OrderPlan { + listName: string + column: string + direction: OrderDirection +} + +/** What the resolution pass needs to decide a key. */ +export interface ResolveContext { + listName: string + listConfig: ListConfig + config: OpenSaasConfig + session: Session | null + context: AccessContext + /** + * Whether field-level `read` access gates a key. False for the Access + * Filter — trusted config, authored by the same person who declares the + * fields — and false under `sudo`, which bypasses access but not the + * vocabulary. + */ + checkFieldRead: boolean + /** Whether a relation quantifier carries the related list's `query` access. */ + applyRelationAccess: boolean + /** + * The lists whose Access Filter is currently being expanded, outermost + * first. Bounds the engine's own recursion — see + * {@link AccessFilterRecursionError}. + */ + accessFilterPath: readonly string[] +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isWhereValue(value: unknown): value is WhereValue { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' || + value instanceof Date + ) +} + +/** + * Prisma 8 ships `like`/`ilike` with no `contains` and renders no `ESCAPE` + * clause, so the pattern is bound verbatim and the engine escapes the + * wildcards itself against Postgres's default backslash escape (ADR-0055). + */ +export function containsPattern(value: string): string { + return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%` +} + +function comparison( + listName: string, + key: string, + operator: 'lt' | 'lte' | 'gt' | 'gte', + raw: unknown, +): ScalarStep { + if (!isWhereValue(raw) || raw === null) { + throw malformedCondition(listName, key, `takes a value for "${operator}"`) + } + return { op: operator, value: raw } +} + +function values( + listName: string, + key: string, + operator: string, + raw: unknown, +): readonly WhereValue[] { + if (!Array.isArray(raw) || !raw.every(isWhereValue)) { + throw malformedCondition(listName, key, `takes a list of values for "${operator}"`) + } + return raw +} + +function resolveScalar(listName: string, key: string, condition: unknown): ScalarStep[] { + if (isWhereValue(condition)) { + return condition === null ? [{ op: 'isNull' }] : [{ op: 'eq', value: condition }] + } + if (!isPlainObject(condition)) { + throw malformedCondition(listName, key, 'is neither a value nor an operator object') + } + + const steps: ScalarStep[] = [] + for (const [operator, raw] of Object.entries(condition)) { + if (!SCALAR_OPERATOR_SET.has(operator)) throw unsupportedOperator(listName, key, operator) + if (raw === undefined) throw undefinedCondition(listName, `${key}.${operator}`) + switch (operator) { + case 'equals': + if (raw === null) steps.push({ op: 'isNull' }) + else if (isWhereValue(raw)) steps.push({ op: 'eq', value: raw }) + else throw malformedCondition(listName, key, 'takes a value for "equals"') + break + case 'not': + if (raw === null) steps.push({ op: 'isNotNull' }) + else if (isWhereValue(raw)) steps.push({ op: 'neq', value: raw }) + else throw malformedCondition(listName, key, 'takes a value for "not"') + break + case 'in': + steps.push({ op: 'in', values: values(listName, key, 'in', raw) }) + break + case 'notIn': + steps.push({ op: 'notIn', values: values(listName, key, 'notIn', raw) }) + break + case 'lt': + case 'lte': + case 'gt': + case 'gte': + steps.push(comparison(listName, key, operator, raw)) + break + case 'contains': + if (typeof raw !== 'string') { + throw malformedCondition(listName, key, 'takes a string for "contains"') + } + steps.push({ op: 'contains', pattern: containsPattern(raw) }) + break + } + } + + if (steps.length === 0) { + throw malformedCondition(listName, key, 'names no operator, so it would constrain nothing') + } + return steps +} + +function branches(listName: string, key: string, value: unknown): readonly unknown[] { + if (value === undefined) throw undefinedCondition(listName, key) + if (Array.isArray(value)) return value + if (isPlainObject(value)) return [value] + throw malformedCondition(listName, key, 'takes a predicate or a list of predicates') +} + +async function resolveBranches( + listName: string, + key: string, + value: unknown, + ctx: ResolveContext, +): Promise { + const plans: WherePlan[] = [] + for (const branch of branches(listName, key, value)) { + if (!isPlainObject(branch)) { + throw malformedCondition(listName, key, 'takes a predicate or a list of predicates') + } + plans.push(await resolveWhere(branch, ctx)) + } + return plans +} + +async function relatedAccessPlan( + related: { listName: string; listConfig: ListConfig }, + ctx: ResolveContext, +): Promise { + if (!ctx.applyRelationAccess) return { kind: 'true' } + const access = await checkAccess(related.listConfig.access?.operation?.query, { + session: ctx.session, + context: ctx.context, + }) + if (access === false) return { kind: 'false' } + if (access === true) return { kind: 'true' } + const filter: PrismaFilter = access + const path = ctx.accessFilterPath + if (path.includes(related.listName)) { + throw new AccessFilterRecursionError([...path, related.listName], 'cycle') + } + if (path.length >= ACCESS_FILTER_MAX_DEPTH) { + throw new AccessFilterRecursionError([...path, related.listName], 'depth') + } + return await resolveWhere(filter, { + ...ctx, + listName: related.listName, + listConfig: related.listConfig, + checkFieldRead: false, + accessFilterPath: [...path, related.listName], + }) +} + +async function resolveRelation( + key: string, + ref: string, + condition: unknown, + ctx: ResolveContext, +): Promise { + const related = getRelatedListConfig(ref, ctx.config) + if (!related) throw unqueryableKey(ctx.listName, key) + if (!isPlainObject(condition)) { + throw malformedCondition(ctx.listName, key, 'takes some, every or none') + } + + const entries = Object.entries(condition) + if (entries.length === 0) { + throw malformedCondition( + ctx.listName, + key, + 'names no quantifier, so it would constrain nothing', + ) + } + + const access = await relatedAccessPlan(related, ctx) + const nodes: WherePlan[] = [] + for (const [quantifier, nested] of entries) { + if (!RELATION_QUANTIFIER_SET.has(quantifier)) + throw unsupportedOperator(ctx.listName, key, quantifier) + if (nested === undefined) throw undefinedCondition(ctx.listName, `${key}.${quantifier}`) + if (!isPlainObject(nested)) { + throw malformedCondition(ctx.listName, `${key}.${quantifier}`, 'takes a predicate') + } + + // Resolved before the denied-list short circuit below, so a malformed + // nested predicate is refused whether or not the session may query the + // related list — otherwise the refusal itself says which lists it may. + const caller = await resolveWhere(nested, { + ...ctx, + listName: related.listName, + listConfig: related.listConfig, + }) + + // A related list the session cannot query is an empty set, not an error: + // `some` is false, `none` and `every` are true. That keeps a relation + // token from distinguishing parent rows by a list the session cannot see. + if (access.kind === 'false') { + nodes.push(quantifier === 'some' ? { kind: 'false' } : { kind: 'true' }) + continue + } + + const scoped = (node: WherePlan): WherePlan => + access.kind === 'true' ? node : { kind: 'and', nodes: [node, access] } + + if (quantifier === 'every') { + // "Every row the caller may SEE also matches", lowered as "no visible + // row fails the predicate". ANDing the access filter into an `every` + // body would instead mean "every related row is visible AND matches", + // which drops a parent for owning a row the caller cannot see — a + // positive signal about an invisible row (spec #1123, story 10). + nodes.push({ + kind: 'relation', + listName: ctx.listName, + relation: key, + relatedListName: related.listName, + quantifier: 'none', + node: scoped({ kind: 'not', node: caller }), + }) + continue + } + + nodes.push({ + kind: 'relation', + listName: ctx.listName, + relation: key, + relatedListName: related.listName, + quantifier: quantifier === 'some' ? 'some' : 'none', + node: scoped(caller), + }) + } + return nodes.length === 1 ? nodes[0] : { kind: 'and', nodes } +} + +async function resolveKey(key: string, value: unknown, ctx: ResolveContext): Promise { + if (value === undefined) throw undefinedCondition(ctx.listName, key) + + const resolved = resolveQueryField(key, ctx.listConfig.fields) + if (!resolved) throw unqueryableKey(ctx.listName, key) + + if (ctx.checkFieldRead && resolved.fieldConfig !== undefined) { + const readable = await isFieldReadableForPredicate(resolved.fieldConfig.access, { + session: ctx.session, + context: ctx.context, + }) + if (!readable) throw unqueryableKey(ctx.listName, key) + } + + if (resolved.isRelationship) { + return await resolveRelation(key, resolved.fieldConfig.ref, value, ctx) + } + return { + kind: 'scalar', + listName: ctx.listName, + column: key, + steps: resolveScalar(ctx.listName, key, value), + } +} + +/** + * Resolve one predicate against a list: every key checked against the config + * and the session, every operator checked against the vocabulary, every + * relation's `query` access decided. Total or throwing — nothing is dropped, + * `sudo` included. + */ +export async function resolveWhere( + where: Record, + ctx: ResolveContext, +): Promise { + const nodes: WherePlan[] = [] + for (const [key, value] of Object.entries(where)) { + if (LOGICAL_OPERATORS.has(key)) { + const resolved = await resolveBranches(ctx.listName, key, value, ctx) + if (key === 'AND') nodes.push({ kind: 'and', nodes: resolved }) + else if (key === 'OR') nodes.push({ kind: 'or', nodes: resolved }) + else nodes.push({ kind: 'not', node: { kind: 'and', nodes: resolved } }) + continue + } + nodes.push(await resolveKey(key, value, ctx)) + } + if (nodes.length === 1) return nodes[0] + return { kind: 'and', nodes } +} + +/** + * Resolve `orderBy`. Scalar-only: Prisma 8's `orderBy` takes columns, so a + * relation — and the to-many count that used to be sortable — is refused + * rather than silently ignored (ADR-0055). + */ +export async function resolveOrderBy( + orders: readonly OrderBy[], + ctx: ResolveContext, +): Promise { + const plans: OrderPlan[] = [] + for (const order of orders) { + for (const [key, direction] of Object.entries(order)) { + if (direction !== 'asc' && direction !== 'desc') { + throw malformedCondition(ctx.listName, key, 'takes the direction "asc" or "desc"') + } + const resolved = resolveQueryField(key, ctx.listConfig.fields) + if (!resolved) throw unqueryableKey(ctx.listName, key) + // The read gate runs before the relationship refusal, as `resolveKey` + // does: a read-denied key must be indistinguishable from one the list + // does not declare, and "orderBy takes scalar columns only" would + // otherwise confirm that a read-denied relationship exists (ADR-0031). + if (ctx.checkFieldRead && resolved.fieldConfig !== undefined) { + const readable = await isFieldReadableForPredicate(resolved.fieldConfig.access, { + session: ctx.session, + context: ctx.context, + }) + if (!readable) throw unqueryableKey(ctx.listName, key) + } + if (resolved.isRelationship) { + throw new ValidationError([ + `Cannot order "${ctx.listName}" by "${key}" — orderBy takes scalar columns only.`, + ]) + } + plans.push({ listName: ctx.listName, column: key, direction }) + } + } + return plans +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index db54414b5..2a3115a64 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -29,7 +29,8 @@ export type { SecuredList, ListQuery, ListPredicate, - ColumnEquality, + ColumnCondition, + ListSort, QueryResult, ColumnFilter, ListWhere, diff --git a/packages/core/src/types/secured-list.ts b/packages/core/src/types/secured-list.ts index 96681565d..4219c8c3f 100644 --- a/packages/core/src/types/secured-list.ts +++ b/packages/core/src/types/secured-list.ts @@ -217,24 +217,67 @@ type SingletonOps = { } /** - * One column's condition in a composed read's predicate. Equality only, which - * is what the engine lowers today; ADR-0055's closed vocabulary widens this in - * its own spec. + * One column's condition in a composed read's predicate: a bare value for + * equality, or the closed Where vocabulary's scalar operators (ADR-0055). + * Several operators on one column are ANDed. `equals: null` is `IS NULL` and + * `not: null` is `IS NOT NULL`; `contains` is case-insensitive and matches its + * value literally. */ -export type ColumnEquality = V | { equals: V } +export type ColumnCondition = + | V + | { + equals?: V + not?: V + in?: readonly V[] + notIn?: readonly V[] + lt?: V + lte?: V + gt?: V + gte?: V + contains?: string + } + +/** + * One relation's condition. Every relation takes the same three quantifiers + * regardless of cardinality: Prisma 8 lowers each to an `EXISTS`, and the + * engine ANDs the related list's own `query` access inside it. + */ +type RelationPredicate = + RelationTarget extends infer Target + ? Target extends keyof R & string + ? { + some?: ListPredicate + every?: ListPredicate + none?: ListPredicate + } + : never + : never -/** What `.where()` takes: the list's own columns, compared for equality. */ +/** What `.where()` takes: the Where vocabulary over this list. */ export type ListPredicate = { - [F in keyof StoredRow]?: ColumnEquality[F]> + [F in keyof StoredRow]?: ColumnCondition[F]> +} & { + [Rel in RelationKey]?: RelationPredicate +} & { + AND?: ListPredicate | readonly ListPredicate[] + OR?: readonly ListPredicate[] + NOT?: ListPredicate | readonly ListPredicate[] +} + +/** What `.orderBy()` takes: the list's own scalar columns and a direction. */ +export type ListSort = { + [F in keyof StoredRow]?: 'asc' | 'desc' } /** - * A composed read: an immutable value carrying the list and its predicates. - * `where` returns a new value and enforces nothing; the terminals resolve - * access, scope the query and materialise (ADR-0041, ADR-0046). + * A composed read: an immutable value carrying the list, its predicates and + * its sort. `where`/`orderBy` return a new value and enforce nothing; the + * terminals resolve access, scope the query and materialise (ADR-0041, + * ADR-0046). */ export type ListQuery = { where: (predicate: ListPredicate) => ListQuery + orderBy: (order: ListSort | readonly ListSort[]) => ListQuery all: () => Promise[]> first: () => Promise | null> } diff --git a/packages/core/tests/access.test.ts b/packages/core/tests/access.test.ts index e9479ca38..a9cc18508 100644 --- a/packages/core/tests/access.test.ts +++ b/packages/core/tests/access.test.ts @@ -9,7 +9,7 @@ import { isBoolean, isPrismaFilter, } from '../src/access/index.js' -import { InvalidCreateAccessResultError } from '../src/access/errors.js' +import { InvalidCreateAccessResultError, UndefinedAccessFilterError } from '../src/access/errors.js' import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js' import { ValidationError } from '../src/hooks/index.js' @@ -227,6 +227,47 @@ describe('Access Control', () => { AND: [accessFilter, userFilter], }) }) + + // The legacy findMany/count/updateMany/delete paths fold their access + // filter in here rather than through the secured builder's Where + // vocabulary, so the total-lowering guarantee the docs state has to hold + // here too (#1147). + describe('an undefined condition in the access filter is refused, not dropped', () => { + it('refuses the shape an anonymous session produces', () => { + expect(() => mergeFilters(undefined, { authorId: undefined })).toThrow( + UndefinedAccessFilterError, + ) + expect(() => mergeFilters({ name: 'John' }, { authorId: undefined })).toThrow( + /condition on "authorId" is undefined/, + ) + }) + + it('refuses it nested under an operator or a branch', () => { + expect(() => mergeFilters(undefined, { authorId: { equals: undefined } })).toThrow( + /condition on "authorId.equals" is undefined/, + ) + expect(() => + mergeFilters(undefined, { OR: [{ published: true }, { authorId: undefined }] }), + ).toThrow(/condition on "OR.1.authorId" is undefined/) + }) + + it("leaves the caller's own filter alone", () => { + expect(mergeFilters({ name: undefined }, true)).toEqual({ name: undefined }) + expect(mergeFilters({ name: undefined }, { authorId: 'a' })).toEqual({ + AND: [{ authorId: 'a' }, { name: undefined }], + }) + }) + + it('is not thrown for a boolean decision, which scopes nothing', () => { + expect(mergeFilters(undefined, false)).toBeNull() + expect(mergeFilters({ authorId: undefined }, false)).toBeNull() + }) + + it('accepts a Date and a null, which are conditions rather than absences', () => { + const accessFilter = { createdAt: { lt: new Date(0) }, deletedAt: null } + expect(mergeFilters(undefined, accessFilter)).toEqual(accessFilter) + }) + }) }) describe('the public entry (#1145)', () => { diff --git a/packages/core/tests/context.test.ts b/packages/core/tests/context.test.ts index df16b6615..46c0e75f7 100644 --- a/packages/core/tests/context.test.ts +++ b/packages/core/tests/context.test.ts @@ -1596,14 +1596,14 @@ describe('getContext', () => { expect(backRelationPrisma.Organisation.count).not.toHaveBeenCalled() }) - it('never mistakes a Prisma filter operator (equals/contains/startsWith/in/is/isNot) for a field name', async () => { + it('never mistakes a Where operator (equals/contains/in/is/isNot) for a field name', async () => { mockPrisma.Post.findMany.mockResolvedValue([ { id: '1', title: 'Test Post', content: 'x', authorId: 'u1' }, ]) const context = await getContext(config, mockPrisma, null) const where = { - title: { equals: 'Test Post', contains: 'Test', startsWith: 'T', in: ['Test Post'] }, + title: { equals: 'Test Post', contains: 'Test', in: ['Test Post'] }, author: { is: { name: 'John' }, isNot: null }, } @@ -2014,12 +2014,12 @@ describe('getContext', () => { await expect( context.db.Organisation.findMany({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }), ).rejects.toThrow(/Document/) await expect( context.db.Organisation.count({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }), ).rejects.toThrow(/Document/) @@ -2034,10 +2034,10 @@ describe('getContext', () => { const context = await getContext(orgConfig, orgPrisma, null) const matching = context.db.Organisation.count({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }).catch((err: Error) => err.message) const nonMatching = context.db.Organisation.count({ - where: { documents: { some: { billingAddress: { startsWith: '99 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '99 ' } } } }, }).catch((err: Error) => err.message) expect(await matching).toEqual(await nonMatching) @@ -2092,7 +2092,7 @@ describe('getContext', () => { const context = await getContext(scopedConfig, orgPrisma, null) await context.db.Organisation.findMany({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }) expect(orgPrisma.Organisation.findMany).toHaveBeenCalledWith( @@ -2100,7 +2100,7 @@ describe('getContext', () => { where: { documents: { some: { - AND: [{ published: { equals: true } }, { billingAddress: { startsWith: '12 ' } }], + AND: [{ published: { equals: true } }, { billingAddress: { contains: '12 ' } }], }, }, }, @@ -2157,12 +2157,12 @@ describe('getContext', () => { await expect( context.db.Organisation.findMany({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }), ).resolves.toEqual([]) await expect( context.db.Organisation.count({ - where: { documents: { some: { billingAddress: { startsWith: '12 ' } } } }, + where: { documents: { some: { billingAddress: { contains: '12 ' } } } }, }), ).resolves.toBe(0) diff --git a/packages/ui/src/components/ListView.tsx b/packages/ui/src/components/ListView.tsx index a8551d82c..8e04967d6 100644 --- a/packages/ui/src/components/ListView.tsx +++ b/packages/ui/src/components/ListView.tsx @@ -21,7 +21,6 @@ import { getUrlKey, isToManyRelationshipField, OpenSaasConfig, - resolveRelationshipCountFilters, } from '@opensaas/stack-core' import type { FieldConfig } from '@opensaas/stack-core' import { isFieldReadableForPredicate } from '@opensaas/stack-core/internal' @@ -221,20 +220,7 @@ export async function ListView({ }) : undefined - // Resolve any to-many relationship count-filter markers (`orders:>5`) into - // access-scoped `{ id: { in } }` fragments. Prisma cannot compare a relation - // count in a `where`, so the filter engine emits a marker the secured - // resolver turns into an id constraint — counting only rows the session may - // see (issue #732). - const whereWithCountFilters = await resolveRelationshipCountFilters( - parsedWhere, - listConfig, - listKey, - { session: context.session, context }, - config, - ) - - const where = whereWithCountFilters + const where = parsedWhere // Build the include: to-one relationships fetch the related row (for its // Item label), while to-many relationships fetch only an access-scoped diff --git a/packages/ui/tests/components/ListView.test.tsx b/packages/ui/tests/components/ListView.test.tsx index 3530f9c91..7eeafc285 100644 --- a/packages/ui/tests/components/ListView.test.tsx +++ b/packages/ui/tests/components/ListView.test.tsx @@ -428,28 +428,22 @@ describe('ListView to-many relationship count sort & filter (issue #732)', () => expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined })) }) - it('resolves a count filter (posts:>5) to an access-scoped id constraint', async () => { - const rows = [ - { id: 'u1', _count: { posts: 7 } }, - { id: 'u2', _count: { posts: 2 } }, - ] - const findMany = vi.fn(async () => rows) + it('shrinks a count filter to presence, and degrades any other comparison', async () => { + const findMany = vi.fn(async () => []) const count = vi.fn(async () => 0) const context = makeContext({ User: { findMany, count } }) - await ListView({ - context, - config, - listKey: 'User', - basePath: '/admin', - search: 'posts:>5', - }) + await ListView({ context, config, listKey: 'User', basePath: '/admin', search: 'posts:>0' }) + expect(findMany.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ where: { posts: { some: {} } } }), + ) - // The main query (and its count) is narrowed to the users whose - // access-visible post count exceeds 5 — only u1. - const mainCall = findMany.mock.calls.at(-1)?.[0] as { where?: unknown } - expect(mainCall.where).toEqual({ id: { in: ['u1'] } }) - expect(count).toHaveBeenCalledWith({ where: { id: { in: ['u1'] } } }) + // Prisma 8 cannot compare a relation count in a `where` (ADR-0055), so a + // bookmarked `posts:>5` degrades to free text rather than filtering. + await ListView({ context, config, listKey: 'User', basePath: '/admin', search: 'posts:>5' }) + expect(findMany.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ where: { name: { contains: '5' } } }), + ) }) }) @@ -491,7 +485,7 @@ describe('ListView to-one relationship label filter (issue #749 / #916)', () => search: 'author:Ada', }) - const expectedWhere = { author: { is: { name: { contains: 'Ada' } } } } + const expectedWhere = { author: { some: { name: { contains: 'Ada' } } } } expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expectedWhere })) expect(count).toHaveBeenCalledWith({ where: expectedWhere }) })