diff --git a/packages/schema/src/plugins/enhancer/policy/expression-writer.ts b/packages/schema/src/plugins/enhancer/policy/expression-writer.ts index 25e7ecd0a..645e02cd1 100644 --- a/packages/schema/src/plugins/enhancer/policy/expression-writer.ts +++ b/packages/schema/src/plugins/enhancer/policy/expression-writer.ts @@ -378,17 +378,9 @@ export class ExpressionWriter { operator = this.negateOperator(operator); } - if (this.isFutureMemberAccess(fieldAccess)) { - // future().field should be treated as the "field" directly, so we - // strip 'future().' and synthesize a reference expr - fieldAccess = { - $type: ReferenceExpr, - $container: fieldAccess.$container, - target: fieldAccess.member, - $resolvedType: fieldAccess.$resolvedType, - $future: true, - } as unknown as ReferenceExpr; - } + // future()...field should be treated as the "field" directly, so we + // strip 'future().' and synthesize a reference/member-access expr + fieldAccess = this.stripFutureCall(fieldAccess); // guard member access of `auth()` with null check if (this.isAuthOrAuthMemberAccess(operand) && !fieldAccess.$resolvedType?.nullable) { @@ -472,6 +464,39 @@ export class ExpressionWriter { ); } + private stripFutureCall(fieldAccess: Expression) { + if (!this.isFutureMemberAccess(fieldAccess)) { + return fieldAccess; + } + + const memberAccessStack: MemberAccessExpr[] = []; + let current: Expression = fieldAccess; + while (isMemberAccessExpr(current)) { + memberAccessStack.push(current); + current = current.operand; + } + + const top = memberAccessStack.pop()!; + + // turn the inner-most member access into a reference expr (strip 'future()') + let result: Expression = { + $type: ReferenceExpr, + $container: top.$container, + target: top.member, + $resolvedType: top.$resolvedType, + args: [], + } satisfies ReferenceExpr; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result as any).$future = true; + + // re-apply member accesses + for (const memberAccess of memberAccessStack.reverse()) { + result = { ...memberAccess, operand: result }; + } + return result; + } + private isFutureMemberAccess(expr: Expression): expr is MemberAccessExpr { if (!isMemberAccessExpr(expr)) { return false; diff --git a/tests/integration/tests/enhancements/with-policy/post-update.test.ts b/tests/integration/tests/enhancements/with-policy/post-update.test.ts index d43804787..21681bee5 100644 --- a/tests/integration/tests/enhancements/with-policy/post-update.test.ts +++ b/tests/integration/tests/enhancements/with-policy/post-update.test.ts @@ -552,4 +552,47 @@ describe('With Policy: post update', () => { expect.arrayContaining([expect.objectContaining({ value: 3 }), expect.objectContaining({ value: 4 })]) ); }); + + it('deep member access', async () => { + const { enhance } = await loadSchema( + ` + model M1 { + id Int @id @default(autoincrement()) + m2 M2? + v1 Int + @@allow('all', true) + @@deny('update', future().m2.m3.v3 > 1) + } + + model M2 { + id Int @id @default(autoincrement()) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id Int @unique + m3 M3? + @@allow('all', true) + } + + model M3 { + id Int @id @default(autoincrement()) + v3 Int + m2 M2 @relation(fields: [m2Id], references:[id]) + m2Id Int @unique + @@allow('all', true) + } + ` + ); + + const db = enhance(); + + await db.m1.create({ + data: { id: 1, v1: 1, m2: { create: { id: 1, m3: { create: { id: 1, v3: 1 } } } } }, + }); + + await db.m1.create({ + data: { id: 2, v1: 2, m2: { create: { id: 2, m3: { create: { id: 2, v3: 2 } } } } }, + }); + + await expect(db.m1.update({ where: { id: 1 }, data: { v1: 2 } })).toResolveTruthy(); + await expect(db.m1.update({ where: { id: 2 }, data: { v1: 3 } })).toBeRejectedByPolicy(); + }); }); diff --git a/tests/regression/tests/issue-1648.test.ts b/tests/regression/tests/issue-1648.test.ts new file mode 100644 index 000000000..67a19e0ed --- /dev/null +++ b/tests/regression/tests/issue-1648.test.ts @@ -0,0 +1,43 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('issue 1648', () => { + it('regression', async () => { + const { prisma, enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + profile Profile? + posts Post[] + } + + model Profile { + id Int @id @default(autoincrement()) + someText String + user User @relation(fields: [userId], references: [id]) + userId Int @unique + } + + model Post { + id Int @id @default(autoincrement()) + title String + + userId Int + user User @relation(fields: [userId], references: [id]) + + // this will always be true, even if the someText field is "canUpdate" + @@deny("update", future().user.profile.someText != "canUpdate") + + @@allow("all", true) + } + ` + ); + + await prisma.user.create({ data: { id: 1, profile: { create: { someText: 'canUpdate' } } } }); + await prisma.user.create({ data: { id: 2, profile: { create: { someText: 'nothing' } } } }); + await prisma.post.create({ data: { id: 1, title: 'Post1', userId: 1 } }); + await prisma.post.create({ data: { id: 2, title: 'Post2', userId: 2 } }); + + const db = enhance(); + await expect(db.post.update({ where: { id: 1 }, data: { title: 'Post1-1' } })).toResolveTruthy(); + await expect(db.post.update({ where: { id: 2 }, data: { title: 'Post2-2' } })).toBeRejectedByPolicy(); + }); +});