Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/quiet-owls-lower.md
Original file line number Diff line number Diff line change
@@ -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.<List>.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`.
4 changes: 3 additions & 1 deletion docs/content/how-to/anonymous-access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions docs/content/reference/config-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the engine refuses a predicate that resolved to undefined" is only true of the secured surface — the findMany path still fails open.

The totality fix lives in resolveWhere, which only context.db.<List>.where(...).all()/.first() goes through. createFindMany (and count/updateMany/delete) still does mergeFilters(scopedWhere, accessResult), which just returns { AND: [accessFilter, userFilter] } — an access filter of { authorId: undefined } reaches the client with the key dropped, i.e. it still matches every row.

So for an app on context.db.Post.findMany() — the API this very file and the generated context's own docblock document — query: ({ session }) => ({ authorId: session?.userId }) remains the silent match-everything read for an anonymous caller, while this doc now tells the reader it is refused. Same for the anonymous-access-control.md hunk.

Either scope the wording to the composed read surface, or route the legacy merge through the same total resolution.

// `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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/mcp/lib/documentation-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ access: {
delete: isOwner,
},
filter: {
query: ({ session }) => ({ userId: { equals: session?.userId } }),
query: ({ session }) => (session ? { userId: { equals: session.userId } } : false),
},
}

Expand Down
41 changes: 40 additions & 1 deletion packages/core/src/access/engine.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -200,6 +200,28 @@ export async function checkCreateAccess<T = Record<string, unknown>>(
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.
Expand All @@ -220,9 +242,21 @@ export async function checkCreateAccess<T = Record<string, unknown>>(
* satisfy. A `PrismaFilter<Post>` from `checkAccess<Post>` 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,
Expand All @@ -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 || {}
}
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/access/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 2 additions & 0 deletions packages/core/src/access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
38 changes: 28 additions & 10 deletions packages/core/src/access/query-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. ` +
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading