Skip to content

Commit ffdad27

Browse files
authored
fix: update rule check for connect with implicit many-to-many relation (#565)
1 parent bba7a3c commit ffdad27

File tree

7 files changed

+553
-8
lines changed

7 files changed

+553
-8
lines changed

packages/runtime/src/enhancements/model-meta.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,10 @@ export function getDefaultModelMeta(): ModelMeta {
2929
export function resolveField(modelMeta: ModelMeta, model: string, field: string) {
3030
return modelMeta.fields[lowerCaseFirst(model)][field];
3131
}
32+
33+
/**
34+
* Gets all fields of a model.
35+
*/
36+
export function getFields(modelMeta: ModelMeta, model: string) {
37+
return modelMeta.fields[lowerCaseFirst(model)];
38+
}

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
PrismaWriteActionType,
1717
} from '../../types';
1818
import { getVersion } from '../../version';
19-
import { resolveField } from '../model-meta';
19+
import { getFields, resolveField } from '../model-meta';
2020
import { NestedWriteVisitor, type VisitorContext } from '../nested-write-vistor';
2121
import type { ModelMeta, PolicyDef, PolicyFunc, ZodSchemas } from '../types';
2222
import {
@@ -294,6 +294,37 @@ export class PolicyUtil {
294294
return;
295295
}
296296

297+
if (injectTarget._count !== undefined) {
298+
// _count needs to respect read policies of related models
299+
if (injectTarget._count === true) {
300+
// include count for all relations, expand to all fields
301+
// so that we can inject guard conditions for each of them
302+
injectTarget._count = { select: {} };
303+
const modelFields = getFields(this.modelMeta, model);
304+
if (modelFields) {
305+
for (const [k, v] of Object.entries(modelFields)) {
306+
if (v.isDataModel && v.isArray) {
307+
// create an entry for to-many relation
308+
injectTarget._count.select[k] = {};
309+
}
310+
}
311+
}
312+
}
313+
314+
// inject conditions for each relation
315+
for (const field of Object.keys(injectTarget._count.select)) {
316+
if (typeof injectTarget._count.select[field] !== 'object') {
317+
injectTarget._count.select[field] = {};
318+
}
319+
const fieldInfo = resolveField(this.modelMeta, model, field);
320+
if (!fieldInfo) {
321+
continue;
322+
}
323+
// inject into the "where" clause inside select
324+
await this.injectAuthGuard(injectTarget._count.select[field], fieldInfo.type, 'read');
325+
}
326+
}
327+
297328
const idFields = this.getIdFields(model);
298329
for (const field of getModelFields(injectTarget)) {
299330
const fieldInfo = resolveField(this.modelMeta, model, field);
@@ -602,6 +633,9 @@ export class PolicyUtil {
602633

603634
// process relation updates: connect, connectOrCreate, and disconnect
604635
const processRelationUpdate = async (model: string, args: any, context: VisitorContext) => {
636+
// CHECK ME: equire the entity being connected readable?
637+
// await this.checkPolicyForFilter(model, args, 'read', this.db);
638+
605639
if (context.field?.backLink) {
606640
// fetch the backlink field of the model being connected
607641
const backLinkField = resolveField(this.modelMeta, model, context.field.backLink);
@@ -720,9 +754,9 @@ export class PolicyUtil {
720754
}
721755
}
722756

723-
private getModelField(model: string, backlinkField: string) {
757+
private getModelField(model: string, field: string) {
724758
model = lowerCaseFirst(model);
725-
return this.modelMeta.fields[model]?.[backlinkField];
759+
return this.modelMeta.fields[model]?.[field];
726760
}
727761

728762
private transaction(db: DbClientContract, action: (tx: Record<string, DbOperations>) => Promise<any>) {

packages/schema/src/plugins/model-meta/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter)
8181
isOptional: ${f.type.optional},
8282
attributes: ${JSON.stringify(getFieldAttributes(f))},
8383
backLink: ${backlink ? "'" + backlink.name + "'" : 'undefined'},
84-
isRelationOwner: ${isRelationOwner(f)},
84+
isRelationOwner: ${isRelationOwner(f, backlink)},
8585
},`);
8686
}
8787
});
@@ -177,6 +177,19 @@ function getUniqueConstraints(model: DataModel) {
177177
return constraints;
178178
}
179179

180-
function isRelationOwner(field: DataModelField) {
181-
return hasAttribute(field, '@relation');
180+
function isRelationOwner(field: DataModelField, backLink: DataModelField | undefined) {
181+
if (!isDataModel(field.type.reference?.ref)) {
182+
return false;
183+
}
184+
185+
if (hasAttribute(field, '@relation')) {
186+
// this field has `@relation` attribute
187+
return true;
188+
} else if (!backLink || !hasAttribute(backLink, '@relation')) {
189+
// if the opposite side field doesn't have `@relation` attribute either,
190+
// it's an implicit many-to-many relation, both sides are owners
191+
return true;
192+
} else {
193+
return false;
194+
}
182195
}

tests/integration/tests/with-policy/connect-disconnect.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ describe('With Policy: connect-disconnect', () => {
1616
model M1 {
1717
id String @id @default(uuid())
1818
m2 M2[]
19+
value Int @default(0)
1920
21+
@@deny('read', value < 0)
2022
@@allow('all', true)
2123
}
2224
@@ -49,6 +51,7 @@ describe('With Policy: connect-disconnect', () => {
4951

5052
const db = withPolicy();
5153

54+
// m1-1 -> m2-1
5255
await db.m2.create({ data: { id: 'm2-1', value: 1, deleted: false } });
5356
await db.m1.create({
5457
data: {
@@ -58,7 +61,9 @@ describe('With Policy: connect-disconnect', () => {
5861
},
5962
},
6063
});
64+
// mark m2-1 deleted
6165
await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: true } });
66+
// disconnect denied because of violation of m2's update rule
6267
await expect(
6368
db.m1.update({
6469
where: { id: 'm1-1' },
@@ -69,7 +74,9 @@ describe('With Policy: connect-disconnect', () => {
6974
},
7075
})
7176
).toBeRejectedByPolicy();
77+
// reset m2-1 delete
7278
await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: false } });
79+
// disconnect allowed
7380
await db.m1.update({
7481
where: { id: 'm1-1' },
7582
data: {
@@ -79,6 +86,7 @@ describe('With Policy: connect-disconnect', () => {
7986
},
8087
});
8188

89+
// connect during create denied
8290
await db.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } });
8391
await expect(
8492
db.m1.create({
@@ -138,6 +146,21 @@ describe('With Policy: connect-disconnect', () => {
138146
},
139147
})
140148
).toBeRejectedByPolicy();
149+
150+
// // connect from m2 to m1, require m1 to be readable
151+
// await db.m2.create({ data: { id: 'm2-7', value: 1 } });
152+
// await prisma.m1.create({ data: { id: 'm1-2', value: -1 } });
153+
// // connect is denied because m1 is not readable
154+
// await expect(
155+
// db.m2.update({
156+
// where: { id: 'm2-7' },
157+
// data: {
158+
// m1: {
159+
// connect: { id: 'm1-2' },
160+
// },
161+
// },
162+
// })
163+
// ).toBeRejectedByPolicy();
141164
});
142165

143166
it('nested to-many', async () => {
@@ -267,4 +290,113 @@ describe('With Policy: connect-disconnect', () => {
267290
})
268291
).toBeRejectedByPolicy();
269292
});
293+
294+
const modelImplicitManyToMany = `
295+
model M1 {
296+
id String @id @default(uuid())
297+
value Int @default(0)
298+
m2 M2[]
299+
300+
@@deny('read', value < 0)
301+
@@allow('all', true)
302+
}
303+
304+
model M2 {
305+
id String @id @default(uuid())
306+
value Int
307+
deleted Boolean @default(false)
308+
m1 M1[]
309+
310+
@@deny('read', value < 0)
311+
@@allow('read,create', true)
312+
@@allow('update', !deleted)
313+
}
314+
`;
315+
316+
it('implicit many-to-many', async () => {
317+
const { withPolicy, prisma } = await loadSchema(modelImplicitManyToMany);
318+
319+
const db = withPolicy();
320+
321+
await prisma.m1.create({ data: { id: 'm1-1', value: 1 } });
322+
await prisma.m2.create({ data: { id: 'm2-1', value: 1 } });
323+
await expect(
324+
db.m1.update({
325+
where: { id: 'm1-1' },
326+
data: { m2: { connect: { id: 'm2-1' } } },
327+
})
328+
).toResolveTruthy();
329+
330+
await prisma.m1.create({ data: { id: 'm1-2', value: 1 } });
331+
await prisma.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } });
332+
// m2-2 not updatable
333+
await expect(
334+
db.m1.update({
335+
where: { id: 'm1-2' },
336+
data: { m2: { connect: { id: 'm2-2' } } },
337+
})
338+
).toBeRejectedByPolicy();
339+
340+
// await prisma.m1.create({ data: { id: 'm1-3', value: -1 } });
341+
// await prisma.m2.create({ data: { id: 'm2-3', value: 1 } });
342+
// // m1-3 not readable
343+
// await expect(
344+
// db.m2.update({
345+
// where: { id: 'm2-3' },
346+
// data: { m1: { connect: { id: 'm1-3' } } },
347+
// })
348+
// ).toBeRejectedByPolicy();
349+
});
350+
351+
const modelExplicitManyToMany = `
352+
model M1 {
353+
id String @id @default(uuid())
354+
value Int @default(0)
355+
m2 M1OnM2[]
356+
357+
@@allow('all', true)
358+
}
359+
360+
model M2 {
361+
id String @id @default(uuid())
362+
value Int
363+
deleted Boolean @default(false)
364+
m1 M1OnM2[]
365+
366+
@@allow('read,create', true)
367+
}
368+
369+
model M1OnM2 {
370+
m1 M1 @relation(fields: [m1Id], references: [id])
371+
m1Id String
372+
m2 M2 @relation(fields: [m2Id], references: [id])
373+
m2Id String
374+
375+
@@id([m1Id, m2Id])
376+
@@allow('read', true)
377+
@@allow('create', !m2.deleted)
378+
}
379+
`;
380+
381+
it('explicit many-to-many', async () => {
382+
const { withPolicy, prisma } = await loadSchema(modelExplicitManyToMany);
383+
384+
const db = withPolicy();
385+
386+
await prisma.m1.create({ data: { id: 'm1-1', value: 1 } });
387+
await prisma.m2.create({ data: { id: 'm2-1', value: 1 } });
388+
await expect(
389+
db.m1OnM2.create({
390+
data: { m1: { connect: { id: 'm1-1' } }, m2: { connect: { id: 'm2-1' } } },
391+
})
392+
).toResolveTruthy();
393+
394+
await prisma.m1.create({ data: { id: 'm1-2', value: 1 } });
395+
await prisma.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } });
396+
await expect(
397+
db.m1OnM2.create({
398+
data: { m1: { connect: { id: 'm1-2' } }, m2: { connect: { id: 'm2-2' } } },
399+
})
400+
).toBeRejectedByPolicy();
401+
});
270402
});

0 commit comments

Comments
 (0)