Skip to content

Commit 6225292

Browse files
authored
fix: incorrect policy injection for post-update rules with deep member access (#1665)
1 parent 62c624d commit 6225292

File tree

3 files changed

+122
-11
lines changed

3 files changed

+122
-11
lines changed

packages/schema/src/plugins/enhancer/policy/expression-writer.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -378,17 +378,9 @@ export class ExpressionWriter {
378378
operator = this.negateOperator(operator);
379379
}
380380

381-
if (this.isFutureMemberAccess(fieldAccess)) {
382-
// future().field should be treated as the "field" directly, so we
383-
// strip 'future().' and synthesize a reference expr
384-
fieldAccess = {
385-
$type: ReferenceExpr,
386-
$container: fieldAccess.$container,
387-
target: fieldAccess.member,
388-
$resolvedType: fieldAccess.$resolvedType,
389-
$future: true,
390-
} as unknown as ReferenceExpr;
391-
}
381+
// future()...field should be treated as the "field" directly, so we
382+
// strip 'future().' and synthesize a reference/member-access expr
383+
fieldAccess = this.stripFutureCall(fieldAccess);
392384

393385
// guard member access of `auth()` with null check
394386
if (this.isAuthOrAuthMemberAccess(operand) && !fieldAccess.$resolvedType?.nullable) {
@@ -472,6 +464,39 @@ export class ExpressionWriter {
472464
);
473465
}
474466

467+
private stripFutureCall(fieldAccess: Expression) {
468+
if (!this.isFutureMemberAccess(fieldAccess)) {
469+
return fieldAccess;
470+
}
471+
472+
const memberAccessStack: MemberAccessExpr[] = [];
473+
let current: Expression = fieldAccess;
474+
while (isMemberAccessExpr(current)) {
475+
memberAccessStack.push(current);
476+
current = current.operand;
477+
}
478+
479+
const top = memberAccessStack.pop()!;
480+
481+
// turn the inner-most member access into a reference expr (strip 'future()')
482+
let result: Expression = {
483+
$type: ReferenceExpr,
484+
$container: top.$container,
485+
target: top.member,
486+
$resolvedType: top.$resolvedType,
487+
args: [],
488+
} satisfies ReferenceExpr;
489+
490+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
491+
(result as any).$future = true;
492+
493+
// re-apply member accesses
494+
for (const memberAccess of memberAccessStack.reverse()) {
495+
result = { ...memberAccess, operand: result };
496+
}
497+
return result;
498+
}
499+
475500
private isFutureMemberAccess(expr: Expression): expr is MemberAccessExpr {
476501
if (!isMemberAccessExpr(expr)) {
477502
return false;

tests/integration/tests/enhancements/with-policy/post-update.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,4 +552,47 @@ describe('With Policy: post update', () => {
552552
expect.arrayContaining([expect.objectContaining({ value: 3 }), expect.objectContaining({ value: 4 })])
553553
);
554554
});
555+
556+
it('deep member access', async () => {
557+
const { enhance } = await loadSchema(
558+
`
559+
model M1 {
560+
id Int @id @default(autoincrement())
561+
m2 M2?
562+
v1 Int
563+
@@allow('all', true)
564+
@@deny('update', future().m2.m3.v3 > 1)
565+
}
566+
567+
model M2 {
568+
id Int @id @default(autoincrement())
569+
m1 M1 @relation(fields: [m1Id], references:[id])
570+
m1Id Int @unique
571+
m3 M3?
572+
@@allow('all', true)
573+
}
574+
575+
model M3 {
576+
id Int @id @default(autoincrement())
577+
v3 Int
578+
m2 M2 @relation(fields: [m2Id], references:[id])
579+
m2Id Int @unique
580+
@@allow('all', true)
581+
}
582+
`
583+
);
584+
585+
const db = enhance();
586+
587+
await db.m1.create({
588+
data: { id: 1, v1: 1, m2: { create: { id: 1, m3: { create: { id: 1, v3: 1 } } } } },
589+
});
590+
591+
await db.m1.create({
592+
data: { id: 2, v1: 2, m2: { create: { id: 2, m3: { create: { id: 2, v3: 2 } } } } },
593+
});
594+
595+
await expect(db.m1.update({ where: { id: 1 }, data: { v1: 2 } })).toResolveTruthy();
596+
await expect(db.m1.update({ where: { id: 2 }, data: { v1: 3 } })).toBeRejectedByPolicy();
597+
});
555598
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { loadSchema } from '@zenstackhq/testtools';
2+
describe('issue 1648', () => {
3+
it('regression', async () => {
4+
const { prisma, enhance } = await loadSchema(
5+
`
6+
model User {
7+
id Int @id @default(autoincrement())
8+
profile Profile?
9+
posts Post[]
10+
}
11+
12+
model Profile {
13+
id Int @id @default(autoincrement())
14+
someText String
15+
user User @relation(fields: [userId], references: [id])
16+
userId Int @unique
17+
}
18+
19+
model Post {
20+
id Int @id @default(autoincrement())
21+
title String
22+
23+
userId Int
24+
user User @relation(fields: [userId], references: [id])
25+
26+
// this will always be true, even if the someText field is "canUpdate"
27+
@@deny("update", future().user.profile.someText != "canUpdate")
28+
29+
@@allow("all", true)
30+
}
31+
`
32+
);
33+
34+
await prisma.user.create({ data: { id: 1, profile: { create: { someText: 'canUpdate' } } } });
35+
await prisma.user.create({ data: { id: 2, profile: { create: { someText: 'nothing' } } } });
36+
await prisma.post.create({ data: { id: 1, title: 'Post1', userId: 1 } });
37+
await prisma.post.create({ data: { id: 2, title: 'Post2', userId: 2 } });
38+
39+
const db = enhance();
40+
await expect(db.post.update({ where: { id: 1 }, data: { title: 'Post1-1' } })).toResolveTruthy();
41+
await expect(db.post.update({ where: { id: 2 }, data: { title: 'Post2-2' } })).toBeRejectedByPolicy();
42+
});
43+
});

0 commit comments

Comments
 (0)