From 53e9165d64bc6fe1b7b6952d07cb2a613e14230f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:48:47 +0800 Subject: [PATCH 1/2] [CI] Bump version 3.8.2 (#2742) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/auth-adapters/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/clients/client-helpers/package.json | 2 +- packages/clients/fetch-client/package.json | 2 +- packages/clients/tanstack-query/package.json | 2 +- packages/common-helpers/package.json | 2 +- packages/config/eslint-config/package.json | 2 +- packages/config/tsdown-config/package.json | 2 +- packages/config/typescript-config/package.json | 2 +- packages/config/vitest-config/package.json | 2 +- packages/create-zenstack/package.json | 2 +- packages/ide/vscode/package.json | 2 +- packages/language/package.json | 2 +- packages/orm/package.json | 2 +- packages/plugins/policy/package.json | 2 +- packages/plugins/soft-delete/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- packages/zod/package.json | 2 +- samples/orm/package.json | 2 +- samples/taskforge/package.json | 2 +- tests/e2e/package.json | 2 +- tests/regression/package.json | 2 +- tests/runtimes/bun/package.json | 2 +- tests/runtimes/edge-runtime/package.json | 2 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index fd7f99f12..bc33d10f8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 8e4ea670c..1f02b95f7 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index 860ec1b7c..85684beef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index d912a2c9f..cb5b3e1de 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index f54ba71f7..15ea345ca 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 5dbf32802..97bcf5152 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 0e760947c..1104ea991 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index f0c924a91..f033cd8a1 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index f6ac021ef..d89327ce0 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.8.1", + "version": "3.8.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index e05c4d88d..39f0fc015 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.8.1", + "version": "3.8.2", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 7a3a073ef..7e2619595 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.8.1", + "version": "3.8.2", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index d2de5fab8..a594402a8 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index b0a6a318a..47cf4d714 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.8.1", + "version": "3.8.2", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 159beb944..b580e8219 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index d9b6b1d36..f0aaf4ab6 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 3bdcfadbd..7b9a5cc51 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 98f7be796..8195098a6 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 335f07a81..fd2672acc 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d0982b233..8d89bdd43 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 2eee5275a..44aaf5acb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index ef0fb3c1f..e778a0196 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index d0d9187e4..1e14f8de4 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index f9f7cf8eb..fe84e78d9 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.8.1", + "version": "3.8.2", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index f2c6d4dfd..e6f05f8a7 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.8.1", + "version": "3.8.2", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index f6887aad9..79eca64f8 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.8.1", + "version": "3.8.2", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index affb61e36..e95b9241f 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.8.1", + "version": "3.8.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 252297158..b705c78c6 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.8.1", + "version": "3.8.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 16c7eafaa..b05dc48ca 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.8.1", + "version": "3.8.2", "private": true, "type": "module", "scripts": { From 05c985a1e25d732e4d760ed7cf73ba6fd61de4b5 Mon Sep 17 00:00:00 2001 From: hechang27-sprt Date: Tue, 30 Jun 2026 17:39:03 +0800 Subject: [PATCH 2/2] fix(policy): resolve this.relation.field against @@allow model, unnest array columns in subqueries (#2734) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .../policy/src/expression-transformer.ts | 103 ++++++--- tests/e2e/orm/policy/auth-access.test.ts | 201 ++++++++++++++++++ 2 files changed, 271 insertions(+), 33 deletions(-) diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 27d77432a..2beb5be73 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -251,6 +251,7 @@ export class ExpressionTransformer { return this.transformValue(false, 'Boolean'); } else { if (ValueListNode.is(right)) { + // simple `in` operator with a list of values, e.g. `field in [1, 2, 3]` return BinaryOperationNode.create(left, OperatorNode.create('in'), right); } else { // array contains @@ -260,6 +261,23 @@ export class ExpressionTransformer { ? // cast lhs otherwise dialect like pg can reject due to type mismatch this.dialect.castText(new ExpressionWrapper(left)).toOperationNode() : left; + if (SelectQueryNode.is(right)) { + // RHS is a correlated subquery selecting an array column (e.g. + // `this.relation.arrayField`). As a scalar subquery it yields the + // array value, so membership must be tested with array-contains. + // `= ANY((subquery))` would instead treat the subquery as a row-set + // and compare the scalar against whole array rows (type error on pg). + const rightFieldDef = this.getFieldDefFromFieldRef(normalizedRight, context); + return this.dialect + .buildArrayContains( + new ExpressionWrapper(right), + new ExpressionWrapper(comparand), + rightFieldDef?.type, + ) + .toOperationNode(); + } + + // path for direct array field (not from relation) return BinaryOperationNode.create( comparand, OperatorNode.create('='), @@ -311,16 +329,12 @@ export class ExpressionTransformer { } private normalizeBinaryOperationOperands(expr: BinaryExpression, context: ExpressionTransformerContext) { - if (context.contextValue) { - // no normalization needed if evaluating against a value object - return { normalizedLeft: expr.left, normalizedRight: expr.right }; - } - - // if relation fields are used directly in comparison, it can only be compared with null, - // so we normalize the args with the id field (use the first id field if multiple) + // If relation fields are used directly in a comparison, normalize both sides to the + // first id field (used for multiple). This applies whether the relation is SQL-backed + // or carried as an auth/binding value object through a collection predicate, so a + // comparison like `relation == binding.relation` reduces to `relation.id == binding.relation.id`. let normalizedLeft: Expression = expr.left; if (this.isRelationField(expr.left, context)) { - invariant(ExpressionUtils.isNull(expr.right), 'only null comparison is supported for relation field'); const leftRelDef = this.getFieldDefFromFieldRef(expr.left, context); invariant(leftRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, leftRelDef.type); @@ -328,7 +342,6 @@ export class ExpressionTransformer { } let normalizedRight: Expression = expr.right; if (this.isRelationField(expr.right, context)) { - invariant(ExpressionUtils.isNull(expr.left), 'only null comparison is supported for relation field'); const rightRelDef = this.getFieldDefFromFieldRef(expr.right, context); invariant(rightRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, rightRelDef.type); @@ -340,7 +353,10 @@ export class ExpressionTransformer { private transformCollectionPredicate(expr: BinaryExpression, context: ExpressionTransformerContext) { this.ensureCollectionPredicateOperator(expr.op); - if (this.isAuthMember(expr.left) || context.contextValue) { + // Evaluate the collection as a value when it comes from `auth()` or when we're already + // iterating a value tree. `this`-rooted collections are excluded: they are relations on + // the `@@allow` model and must be resolved via SQL joins, not the in-memory value tree. + if (this.isAuthMember(expr.left) || (context.contextValue && !this.isThisRootedMember(expr.left))) { invariant( ExpressionUtils.isMember(expr.left) || ExpressionUtils.isField(expr.left), 'expected member or field expression', @@ -409,6 +425,8 @@ export class ExpressionTransformer { ...context, modelOrType: newContextModel, alias: undefined, + // binding values (if any) remain available through `bindingScope` + contextValue: undefined, bindingScope: bindingScope, }); @@ -691,7 +709,8 @@ export class ExpressionTransformer { if (ExpressionUtils.isThis(expr.receiver)) { if (expr.members.length === 1) { - // `this.relation` case, equivalent to field access + // `this.relation` case, equivalent to field access; keep memberFilter/memberSelect + // so `_field` can build the collection-predicate subquery (count/filter) return this._field(ExpressionUtils.field(expr.members[0]!), { ...context, alias: context.thisAlias, @@ -700,9 +719,15 @@ export class ExpressionTransformer { contextValue: undefined, }); } else { - // transform the first segment into a relation access, then continue with the rest of the members + // transform the first segment into a relation access, then continue with the rest of + // the members; root the chain at the correct context model (thisType/thisAlias) const firstMemberFieldDef = QueryUtils.requireField(this.schema, context.thisType, expr.members[0]!); - receiver = this.transformRelationAccess(expr.members[0]!, firstMemberFieldDef.type, restContext); + receiver = this.transformRelationAccess(expr.members[0]!, firstMemberFieldDef.type, { + ...restContext, + alias: context.thisAlias, + modelOrType: context.thisType, + contextValue: undefined, + }); members = expr.members.slice(1); // startType should be the type of the relation access startType = firstMemberFieldDef.type; @@ -977,6 +1002,10 @@ export class ExpressionTransformer { return ExpressionUtils.isMember(expr) && this.isAuthCall(expr.receiver); } + private isThisRootedMember(expr: Expression) { + return ExpressionUtils.isMember(expr) && ExpressionUtils.isThis(expr.receiver); + } + private isNullNode(node: OperationNode) { return ValueNode.is(node) && node.value === null; } @@ -1007,29 +1036,37 @@ export class ExpressionTransformer { ExpressionUtils.isMember(expr) && ExpressionUtils.isThis(expr.receiver) ? context.thisType : context.modelOrType; - if (ExpressionUtils.isField(expr)) { - return QueryUtils.getField(this.schema, model, expr.field); - } else if ( - ExpressionUtils.isMember(expr) && - expr.members.length === 1 && - ExpressionUtils.isThis(expr.receiver) - ) { - return QueryUtils.getField(this.schema, model, expr.members[0]!); - } else if (ExpressionUtils.isMember(expr) && ExpressionUtils.isField(expr.receiver)) { - // relation chain access (e.g. `owner.id`, `user.profile.uuid_field`): walk the - // relation hops and return the terminal field's FieldDef so native-type info - // (@db.*) is available for casting in buildComparison - const receiverDef = QueryUtils.getField(this.schema, model, expr.receiver.field); - if (!receiverDef?.relation) return undefined; - let currModel = receiverDef.type; - for (let i = 0; i < expr.members.length - 1; i++) { - const hopDef = QueryUtils.getField(this.schema, currModel, expr.members[i]!); + + // walks a chain of member names from `startModel`, treating all but the last segment as + // relation hops, and returns the terminal field's FieldDef; returns undefined if any + // segment is missing or an intermediate hop is not a relation. + const walkRelationChain = (startModel: string, members: string[]): FieldDef | undefined => { + let currModel = startModel; + for (let i = 0; i < members.length - 1; i++) { + const hopDef = QueryUtils.getField(this.schema, currModel, members[i]!); if (!hopDef?.relation) return undefined; currModel = hopDef.type; } - return QueryUtils.getField(this.schema, currModel, expr.members[expr.members.length - 1]!); - } else { - return undefined; + return QueryUtils.getField(this.schema, currModel, members[members.length - 1]!); + }; + + if (ExpressionUtils.isField(expr)) { + return QueryUtils.getField(this.schema, model, expr.field); + } else if (ExpressionUtils.isMember(expr)) { + if (ExpressionUtils.isThis(expr.receiver)) { + // `this.<...>.field` chain rooted at the `this` model. + return walkRelationChain(model, expr.members); + } else if (ExpressionUtils.isBinding(expr.receiver)) { + // `binding.<...>.field` chain rooted at a collection-predicate binding. + const binding = this.requireBindingScope(expr.receiver, context); + return walkRelationChain(binding.type, expr.members); + } else if (ExpressionUtils.isField(expr.receiver)) { + // relation chain access (e.g. `owner.id`, `user.profile.uuid_field`): the field + // receiver is the first hop, so native-type info (@db.*) on the terminal field is + // available for casting in buildComparison. + return walkRelationChain(model, [expr.receiver.field, ...expr.members]); + } } + return undefined; } } diff --git a/tests/e2e/orm/policy/auth-access.test.ts b/tests/e2e/orm/policy/auth-access.test.ts index 56942de49..18de1a1d8 100644 --- a/tests/e2e/orm/policy/auth-access.test.ts +++ b/tests/e2e/orm/policy/auth-access.test.ts @@ -475,4 +475,205 @@ model Channel { userDb2.channel.update({ where: { id: 1 }, data: { name: 'general-updated' } }), ).resolves.toBeTruthy(); }); + + it('resolves this.relation.field against @@allow model in collection predicates', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + level Int + permissions Permission[] + posts Post[] + @@auth +} + +model Permission { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + clearance Int +} + +model Post { + id Int @id @default(autoincrement()) + author User @relation(fields: [authorId], references: [id]) + authorId Int + + @@allow('read', auth().permissions?[p, p.clearance >= this.author.level]) +} +`, + { provider: 'postgresql' }, + ); + + await db.$unuseAll().post.create({ + data: { id: 1, author: { create: { id: 1, level: 5 } } }, + }); + await db.$unuseAll().post.create({ + data: { id: 2, author: { create: { id: 2, level: 10 } } }, + }); + + // no auth: no permissions → cannot read any post + await expect(db.post.findMany()).resolves.toHaveLength(0); + + // clearance 5: can read author level ≤ 5 → only post 1 (author level 5) + const user1 = db.$setAuth({ + id: 3, + permissions: [{ id: 1, clearance: 5 }], + }); + const posts1 = await user1.post.findMany(); + expect(posts1.map((p) => p.id).sort()).toEqual([1]); + + // clearance 10: can read author level ≤ 10 → both posts + const user2 = db.$setAuth({ + id: 4, + permissions: [{ id: 2, clearance: 10 }], + }); + const posts2 = await user2.post.findMany(); + expect(posts2.map((p) => p.id).sort()).toEqual([1, 2]); + }); + + it('handles this.relation.arrayField with in operator', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + permissions Permission[] + @@auth +} + +model Group { + id Int @id @default(autoincrement()) + visibleDocIds Int[] + docs Doc[] +} + +model Permission { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + allowedDocIds Int[] +} + +model Doc { + id Int @id @default(autoincrement()) + group Group @relation(fields: [groupId], references: [id]) + groupId Int + + @@allow('read', + auth().permissions?[p, this.id in p.allowedDocIds] || + this.id in this.group.visibleDocIds + ) +} +`, + { provider: 'postgresql' }, + ); + + await db.$unuseAll().group.create({ + data: { id: 1, visibleDocIds: [1] }, + }); + await db.$unuseAll().group.create({ + data: { id: 2, visibleDocIds: [] }, + }); + await db.$unuseAll().user.create({ + data: { id: 1 }, + }); + await db.$unuseAll().user.create({ + data: { id: 2 }, + }); + await db.$unuseAll().permission.create({ + data: { id: 10, userId: 2, allowedDocIds: [2] }, + }); + await db.$unuseAll().doc.createMany({ + data: [ + { id: 1, groupId: 1 }, + { id: 2, groupId: 2 }, + ], + }); + + // User 1 (no perms): doc 1 visible via group.visibleDocIds + const user1 = db.$setAuth({ id: 1, permissions: [] }); + expect((await user1.doc.findMany()).map((d) => d.id).sort()).toEqual([1]); + + // User 2 (perm allows doc 2): sees doc 1 (group-visible) + doc 2 (permission) + const user2 = db.$setAuth({ + id: 2, + permissions: [{ id: 10, allowedDocIds: [2] }], + }); + expect((await user2.doc.findMany()).map((d) => d.id).sort()).toEqual([1, 2]); + }); + + it('keeps this-rooted collection predicates on the @@allow model inside auth bindings', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + assignments RoleAssignment[] + @@auth +} + +model Scope { + id Int @id + parentId Int? + parent Scope? @relation("ScopeParent", fields: [parentId], references: [id]) + children Scope[] @relation("ScopeParent") + ancestors ScopeClosure[] @relation("Descendant") + descendants ScopeClosure[] @relation("Ancestor") + docs Doc[] + assignments RoleAssignment[] + @@allow('all', true) +} + +model ScopeClosure { + ancestorId Int + descendantId Int + ancestor Scope @relation("Ancestor", fields: [ancestorId], references: [id]) + descendant Scope @relation("Descendant", fields: [descendantId], references: [id]) + @@id([ancestorId, descendantId]) + @@allow('all', true) +} + +model RoleAssignment { + id Int @id + userId Int + scopeId Int + user User @relation(fields: [userId], references: [id]) + scope Scope @relation(fields: [scopeId], references: [id]) + @@allow('all', true) +} + +model Doc { + id Int @id + authScopeId Int + authScope Scope @relation(fields: [authScopeId], references: [id]) + + @@allow('read', auth().assignments?[rs, + rs.scope == this.authScope || + this.authScope.ancestors?[ancestor == rs.scope] + ]) +} +`, + { provider: 'postgresql' }, + ); + + await db.$unuseAll().scope.createMany({ + data: [{ id: 1 }, { id: 2, parentId: 1 }], + }); + await db.$unuseAll().scopeClosure.createMany({ + data: [ + { ancestorId: 1, descendantId: 1 }, + { ancestorId: 2, descendantId: 2 }, + { ancestorId: 1, descendantId: 2 }, + ], + }); + await db.$unuseAll().doc.create({ + data: { id: 1, authScopeId: 2 }, + }); + + const reader = db.$setAuth({ + id: 1, + assignments: [{ id: 1, scopeId: 1, scope: { id: 1 } }], + }); + + await expect(reader.doc.findUnique({ where: { id: 1 } })).toResolveTruthy(); + }); });