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
47 changes: 36 additions & 11 deletions packages/schema/src/plugins/enhancer/policy/expression-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
43 changes: 43 additions & 0 deletions tests/regression/tests/issue-1648.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});