Skip to content

Commit 19a3b5d

Browse files
authored
fix: field-level policy should filter out records when the field used for filtering is not allowed to read (#1661)
1 parent 1d81325 commit 19a3b5d

File tree

3 files changed

+55
-24
lines changed

3 files changed

+55
-24
lines changed

packages/runtime/src/enhancements/policy/policy-utils.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,11 @@ export class PolicyUtil extends QueryUtils {
473473

474474
let mergedGuard = guard;
475475
if (args.where) {
476-
// inject into relation fields:
476+
// inject into fields:
477477
// to-many: some/none/every
478478
// to-one: direct-conditions/is/isNot
479-
mergedGuard = this.injectReadGuardForRelationFields(db, model, args.where, guard);
479+
// regular fields
480+
mergedGuard = this.buildReadGuardForFields(db, model, args.where, guard);
480481
}
481482

482483
args.where = this.and(args.where, mergedGuard);
@@ -485,7 +486,7 @@ export class PolicyUtil extends QueryUtils {
485486

486487
// Injects guard for relation fields nested in `payload`. The `modelGuard` parameter represents the model-level guard for `model`.
487488
// The function returns a modified copy of `modelGuard` with field-level policies combined.
488-
private injectReadGuardForRelationFields(db: CrudContract, model: string, payload: any, modelGuard: any) {
489+
private buildReadGuardForFields(db: CrudContract, model: string, payload: any, modelGuard: any) {
489490
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
490491
return modelGuard;
491492
}
@@ -530,12 +531,12 @@ export class PolicyUtil extends QueryUtils {
530531
) {
531532
const guard = this.getAuthGuard(db, fieldInfo.type, 'read');
532533
if (payload.some) {
533-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.some, guard);
534+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload.some, guard);
534535
// turn "some" into: { some: { AND: [guard, payload.some] } }
535536
payload.some = this.and(payload.some, mergedGuard);
536537
}
537538
if (payload.none) {
538-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.none, guard);
539+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload.none, guard);
539540
// turn none into: { none: { AND: [guard, payload.none] } }
540541
payload.none = this.and(payload.none, mergedGuard);
541542
}
@@ -545,7 +546,7 @@ export class PolicyUtil extends QueryUtils {
545546
// ignore empty every clause
546547
Object.keys(payload.every).length > 0
547548
) {
548-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.every, guard);
549+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload.every, guard);
549550

550551
// turn "every" into: { none: { AND: [guard, { NOT: payload.every }] } }
551552
if (!payload.none) {
@@ -569,18 +570,18 @@ export class PolicyUtil extends QueryUtils {
569570

570571
if (payload.is !== undefined || payload.isNot !== undefined) {
571572
if (payload.is) {
572-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.is, guard);
573+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload.is, guard);
573574
// merge guard with existing "is": { is: { AND: [originalIs, guard] } }
574575
payload.is = this.and(payload.is, mergedGuard);
575576
}
576577

577578
if (payload.isNot) {
578-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.isNot, guard);
579+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload.isNot, guard);
579580
// merge guard with existing "isNot": { isNot: { AND: [originalIsNot, guard] } }
580581
payload.isNot = this.and(payload.isNot, mergedGuard);
581582
}
582583
} else {
583-
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload, guard);
584+
const mergedGuard = this.buildReadGuardForFields(db, fieldInfo.type, payload, guard);
584585
// turn direct conditions into: { is: { AND: [ originalConditions, guard ] } }
585586
const combined = this.and(clone(payload), mergedGuard);
586587
Object.keys(payload).forEach((key) => delete payload[key]);
@@ -600,18 +601,22 @@ export class PolicyUtil extends QueryUtils {
600601
}
601602

602603
if (args.where) {
603-
// inject into relation fields:
604+
// inject into fields:
604605
// to-many: some/none/every
605606
// to-one: direct-conditions/is/isNot
606-
this.injectReadGuardForRelationFields(db, model, args.where, {});
607+
// regular fields
608+
const mergedGuard = this.buildReadGuardForFields(db, model, args.where, {});
609+
this.mergeWhereClause(args.where, mergedGuard);
607610
}
608611

609-
if (injected.where && Object.keys(injected.where).length > 0 && !this.isTrue(injected.where)) {
610-
if (!args.where) {
611-
args.where = injected.where;
612-
} else {
612+
if (args.where) {
613+
if (injected.where && Object.keys(injected.where).length > 0) {
614+
// merge injected guard with the user-provided where clause
613615
this.mergeWhereClause(args.where, injected.where);
614616
}
617+
} else if (injected.where) {
618+
// no user-provided where clause, use the injected one
619+
args.where = injected.where;
615620
}
616621

617622
// recursively inject read guard conditions into nested select, include, and _count

tests/integration/tests/enhancements/with-policy/create-many-and-return.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,17 @@ describe('Test API createManyAndReturn', () => {
9292

9393
const db = enhance();
9494

95-
const r = await db.post.createManyAndReturn({
96-
data: [
97-
{ title: 'post1', published: true },
98-
{ title: 'post2', published: false },
99-
],
100-
});
101-
expect(r).toHaveLength(2);
102-
expect(r[0].title).toBe('post1');
103-
expect(r[1].title).toBeUndefined();
95+
// create should succeed but one result can't be read back
96+
await expect(
97+
db.post.createManyAndReturn({
98+
data: [
99+
{ title: 'post1', published: true },
100+
{ title: 'post2', published: false },
101+
],
102+
})
103+
).toBeRejectedByPolicy();
104+
105+
// check posts are created
106+
await expect(prisma.post.findMany()).resolves.toHaveLength(2);
104107
});
105108
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { loadSchema } from '@zenstackhq/testtools';
2+
describe('issue 1644', () => {
3+
it('regression', async () => {
4+
const { prisma, enhance } = await loadSchema(
5+
`
6+
model User {
7+
id Int @id @default(autoincrement())
8+
email String @unique @email @length(6, 32) @allow('read', auth() == this)
9+
10+
// full access to all
11+
@@allow('all', true)
12+
}
13+
`
14+
);
15+
16+
await prisma.user.create({ data: { id: 1, email: '[email protected]' } });
17+
await prisma.user.create({ data: { id: 2, email: '[email protected]' } });
18+
19+
const db = enhance({ id: 1 });
20+
await expect(db.user.count({ where: { email: { contains: 'example.com' } } })).resolves.toBe(1);
21+
await expect(db.user.findMany({ where: { email: { contains: 'example.com' } } })).resolves.toHaveLength(1);
22+
});
23+
});

0 commit comments

Comments
 (0)