diff --git a/packages/runtime/src/enhancements/model-meta.ts b/packages/runtime/src/enhancements/model-meta.ts index 8109bbacc..ee480db5c 100644 --- a/packages/runtime/src/enhancements/model-meta.ts +++ b/packages/runtime/src/enhancements/model-meta.ts @@ -29,3 +29,10 @@ export function getDefaultModelMeta(): ModelMeta { export function resolveField(modelMeta: ModelMeta, model: string, field: string) { return modelMeta.fields[lowerCaseFirst(model)][field]; } + +/** + * Gets all fields of a model. + */ +export function getFields(modelMeta: ModelMeta, model: string) { + return modelMeta.fields[lowerCaseFirst(model)]; +} diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 08d8034ae..c97fa1537 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -16,7 +16,7 @@ import { PrismaWriteActionType, } from '../../types'; import { getVersion } from '../../version'; -import { resolveField } from '../model-meta'; +import { getFields, resolveField } from '../model-meta'; import { NestedWriteVisitor, type VisitorContext } from '../nested-write-vistor'; import type { ModelMeta, PolicyDef, PolicyFunc, ZodSchemas } from '../types'; import { @@ -294,6 +294,37 @@ export class PolicyUtil { return; } + if (injectTarget._count !== undefined) { + // _count needs to respect read policies of related models + if (injectTarget._count === true) { + // include count for all relations, expand to all fields + // so that we can inject guard conditions for each of them + injectTarget._count = { select: {} }; + const modelFields = getFields(this.modelMeta, model); + if (modelFields) { + for (const [k, v] of Object.entries(modelFields)) { + if (v.isDataModel && v.isArray) { + // create an entry for to-many relation + injectTarget._count.select[k] = {}; + } + } + } + } + + // inject conditions for each relation + for (const field of Object.keys(injectTarget._count.select)) { + if (typeof injectTarget._count.select[field] !== 'object') { + injectTarget._count.select[field] = {}; + } + const fieldInfo = resolveField(this.modelMeta, model, field); + if (!fieldInfo) { + continue; + } + // inject into the "where" clause inside select + await this.injectAuthGuard(injectTarget._count.select[field], fieldInfo.type, 'read'); + } + } + const idFields = this.getIdFields(model); for (const field of getModelFields(injectTarget)) { const fieldInfo = resolveField(this.modelMeta, model, field); @@ -602,6 +633,9 @@ export class PolicyUtil { // process relation updates: connect, connectOrCreate, and disconnect const processRelationUpdate = async (model: string, args: any, context: VisitorContext) => { + // CHECK ME: equire the entity being connected readable? + // await this.checkPolicyForFilter(model, args, 'read', this.db); + if (context.field?.backLink) { // fetch the backlink field of the model being connected const backLinkField = resolveField(this.modelMeta, model, context.field.backLink); @@ -720,9 +754,9 @@ export class PolicyUtil { } } - private getModelField(model: string, backlinkField: string) { + private getModelField(model: string, field: string) { model = lowerCaseFirst(model); - return this.modelMeta.fields[model]?.[backlinkField]; + return this.modelMeta.fields[model]?.[field]; } private transaction(db: DbClientContract, action: (tx: Record) => Promise) { diff --git a/packages/schema/src/plugins/model-meta/index.ts b/packages/schema/src/plugins/model-meta/index.ts index 2198c4b43..892f57c36 100644 --- a/packages/schema/src/plugins/model-meta/index.ts +++ b/packages/schema/src/plugins/model-meta/index.ts @@ -81,7 +81,7 @@ function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter) isOptional: ${f.type.optional}, attributes: ${JSON.stringify(getFieldAttributes(f))}, backLink: ${backlink ? "'" + backlink.name + "'" : 'undefined'}, - isRelationOwner: ${isRelationOwner(f)}, + isRelationOwner: ${isRelationOwner(f, backlink)}, },`); } }); @@ -177,6 +177,19 @@ function getUniqueConstraints(model: DataModel) { return constraints; } -function isRelationOwner(field: DataModelField) { - return hasAttribute(field, '@relation'); +function isRelationOwner(field: DataModelField, backLink: DataModelField | undefined) { + if (!isDataModel(field.type.reference?.ref)) { + return false; + } + + if (hasAttribute(field, '@relation')) { + // this field has `@relation` attribute + return true; + } else if (!backLink || !hasAttribute(backLink, '@relation')) { + // if the opposite side field doesn't have `@relation` attribute either, + // it's an implicit many-to-many relation, both sides are owners + return true; + } else { + return false; + } } diff --git a/tests/integration/tests/with-policy/connect-disconnect.test.ts b/tests/integration/tests/with-policy/connect-disconnect.test.ts index 613b8ed53..fab86ac58 100644 --- a/tests/integration/tests/with-policy/connect-disconnect.test.ts +++ b/tests/integration/tests/with-policy/connect-disconnect.test.ts @@ -16,7 +16,9 @@ describe('With Policy: connect-disconnect', () => { model M1 { id String @id @default(uuid()) m2 M2[] + value Int @default(0) + @@deny('read', value < 0) @@allow('all', true) } @@ -49,6 +51,7 @@ describe('With Policy: connect-disconnect', () => { const db = withPolicy(); + // m1-1 -> m2-1 await db.m2.create({ data: { id: 'm2-1', value: 1, deleted: false } }); await db.m1.create({ data: { @@ -58,7 +61,9 @@ describe('With Policy: connect-disconnect', () => { }, }, }); + // mark m2-1 deleted await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: true } }); + // disconnect denied because of violation of m2's update rule await expect( db.m1.update({ where: { id: 'm1-1' }, @@ -69,7 +74,9 @@ describe('With Policy: connect-disconnect', () => { }, }) ).toBeRejectedByPolicy(); + // reset m2-1 delete await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: false } }); + // disconnect allowed await db.m1.update({ where: { id: 'm1-1' }, data: { @@ -79,6 +86,7 @@ describe('With Policy: connect-disconnect', () => { }, }); + // connect during create denied await db.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } }); await expect( db.m1.create({ @@ -138,6 +146,21 @@ describe('With Policy: connect-disconnect', () => { }, }) ).toBeRejectedByPolicy(); + + // // connect from m2 to m1, require m1 to be readable + // await db.m2.create({ data: { id: 'm2-7', value: 1 } }); + // await prisma.m1.create({ data: { id: 'm1-2', value: -1 } }); + // // connect is denied because m1 is not readable + // await expect( + // db.m2.update({ + // where: { id: 'm2-7' }, + // data: { + // m1: { + // connect: { id: 'm1-2' }, + // }, + // }, + // }) + // ).toBeRejectedByPolicy(); }); it('nested to-many', async () => { @@ -267,4 +290,113 @@ describe('With Policy: connect-disconnect', () => { }) ).toBeRejectedByPolicy(); }); + + const modelImplicitManyToMany = ` + model M1 { + id String @id @default(uuid()) + value Int @default(0) + m2 M2[] + + @@deny('read', value < 0) + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m1 M1[] + + @@deny('read', value < 0) + @@allow('read,create', true) + @@allow('update', !deleted) + } + `; + + it('implicit many-to-many', async () => { + const { withPolicy, prisma } = await loadSchema(modelImplicitManyToMany); + + const db = withPolicy(); + + await prisma.m1.create({ data: { id: 'm1-1', value: 1 } }); + await prisma.m2.create({ data: { id: 'm2-1', value: 1 } }); + await expect( + db.m1.update({ + where: { id: 'm1-1' }, + data: { m2: { connect: { id: 'm2-1' } } }, + }) + ).toResolveTruthy(); + + await prisma.m1.create({ data: { id: 'm1-2', value: 1 } }); + await prisma.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } }); + // m2-2 not updatable + await expect( + db.m1.update({ + where: { id: 'm1-2' }, + data: { m2: { connect: { id: 'm2-2' } } }, + }) + ).toBeRejectedByPolicy(); + + // await prisma.m1.create({ data: { id: 'm1-3', value: -1 } }); + // await prisma.m2.create({ data: { id: 'm2-3', value: 1 } }); + // // m1-3 not readable + // await expect( + // db.m2.update({ + // where: { id: 'm2-3' }, + // data: { m1: { connect: { id: 'm1-3' } } }, + // }) + // ).toBeRejectedByPolicy(); + }); + + const modelExplicitManyToMany = ` + model M1 { + id String @id @default(uuid()) + value Int @default(0) + m2 M1OnM2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m1 M1OnM2[] + + @@allow('read,create', true) + } + + model M1OnM2 { + m1 M1 @relation(fields: [m1Id], references: [id]) + m1Id String + m2 M2 @relation(fields: [m2Id], references: [id]) + m2Id String + + @@id([m1Id, m2Id]) + @@allow('read', true) + @@allow('create', !m2.deleted) + } + `; + + it('explicit many-to-many', async () => { + const { withPolicy, prisma } = await loadSchema(modelExplicitManyToMany); + + const db = withPolicy(); + + await prisma.m1.create({ data: { id: 'm1-1', value: 1 } }); + await prisma.m2.create({ data: { id: 'm2-1', value: 1 } }); + await expect( + db.m1OnM2.create({ + data: { m1: { connect: { id: 'm1-1' } }, m2: { connect: { id: 'm2-1' } } }, + }) + ).toResolveTruthy(); + + await prisma.m1.create({ data: { id: 'm1-2', value: 1 } }); + await prisma.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } }); + await expect( + db.m1OnM2.create({ + data: { m1: { connect: { id: 'm1-2' } }, m2: { connect: { id: 'm2-2' } } }, + }) + ).toBeRejectedByPolicy(); + }); }); diff --git a/tests/integration/tests/with-policy/relation-many-to-many-filter.test.ts b/tests/integration/tests/with-policy/relation-many-to-many-filter.test.ts new file mode 100644 index 000000000..fe0c686db --- /dev/null +++ b/tests/integration/tests/with-policy/relation-many-to-many-filter.test.ts @@ -0,0 +1,296 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: relation many-to-many filter', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + const model = ` + model M1 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m2 M2[] + + @@allow('read', !deleted) + @@allow('create', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m1 M1[] + + @@allow('read', !deleted) + @@allow('create', true) + } + `; + + it('some filter', async () => { + const { withPolicy } = await loadSchema(model); + + const db = withPolicy(); + + await db.m1.create({ + data: { + id: '1', + value: 1, + m2: { + create: [ + { + id: '1', + value: 1, + }, + { + id: '2', + value: 2, + deleted: true, + }, + ], + }, + }, + }); + + // m1 -> m2 lookup + const r = await db.m1.findFirst({ + where: { + id: '1', + m2: { + some: {}, + }, + }, + include: { + _count: { select: { m2: true } }, + }, + }); + expect(r._count.m2).toBe(1); + + // m2 -> m1 lookup + await expect( + db.m2.findFirst({ + where: { + id: '1', + m1: { + some: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + // m1 with empty m2 list + await db.m1.create({ + data: { + id: '2', + value: 1, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + some: {}, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + some: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + }); + + it('none filter', async () => { + const { withPolicy } = await loadSchema(model); + + const db = withPolicy(); + + await db.m1.create({ + data: { + id: '1', + value: 1, + m2: { + create: [ + { id: '1', value: 1 }, + { id: '2', value: 2, deleted: true }, + ], + }, + }, + }); + + // m1 -> m2 lookup + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: {}, + }, + }, + }) + ).toResolveFalsy(); + + // m2 -> m1 lookup + await expect( + db.m2.findFirst({ + where: { + m1: { + none: {}, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with empty m2 list + await db.m1.create({ + data: { + id: '2', + value: 2, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + none: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + none: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('every filter', async () => { + const { withPolicy } = await loadSchema(model); + + const db = withPolicy(); + + await db.m1.create({ + data: { + id: '1', + value: 1, + m2: { + create: [ + { id: '1', value: 1 }, + { id: '2', value: 2, deleted: true }, + ], + }, + }, + }); + + // m1 -> m2 lookup + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: {}, + }, + }, + }) + ).toResolveTruthy(); + + // m2 -> m1 lookup + await expect( + db.m2.findFirst({ + where: { + id: '1', + m1: { + every: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + // m1 with empty m2 list + await db.m1.create({ + data: { + id: '2', + value: 2, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + every: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + every: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/with-policy/relation-to-many-filter.test.ts b/tests/integration/tests/with-policy/relation-one-to-many-filter.test.ts similarity index 79% rename from tests/integration/tests/with-policy/relation-to-many-filter.test.ts rename to tests/integration/tests/with-policy/relation-one-to-many-filter.test.ts index e66d35761..6ccaa6b9d 100644 --- a/tests/integration/tests/with-policy/relation-to-many-filter.test.ts +++ b/tests/integration/tests/with-policy/relation-one-to-many-filter.test.ts @@ -1,7 +1,7 @@ import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; -describe('With Policy: relation to-many filter', () => { +describe('With Policy: relation one-to-many filter', () => { let origDir: string; beforeAll(async () => { @@ -397,4 +397,67 @@ describe('With Policy: relation to-many filter', () => { }) ).toResolveTruthy(); }); + + it('_count filter', async () => { + const { withPolicy } = await loadSchema(model); + + const db = withPolicy(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + value: 1, + m3: { + create: { + value: 1, + }, + }, + }, + { + value: 2, + deleted: true, + m3: { + create: { + value: 2, + deleted: true, + }, + }, + }, + ], + }, + }, + }); + + await expect(db.m1.findFirst({ include: { _count: true } })).resolves.toEqual( + expect.objectContaining({ _count: { m2: 1 } }) + ); + await expect(db.m1.findFirst({ include: { _count: { select: { m2: true } } } })).resolves.toEqual( + expect.objectContaining({ _count: { m2: 1 } }) + ); + await expect( + db.m1.findFirst({ include: { _count: { select: { m2: { where: { value: { gt: 0 } } } } } } }) + ).resolves.toEqual(expect.objectContaining({ _count: { m2: 1 } })); + await expect( + db.m1.findFirst({ include: { _count: { select: { m2: { where: { value: { gt: 1 } } } } } } }) + ).resolves.toEqual(expect.objectContaining({ _count: { m2: 0 } })); + + const t = await db.m1.findFirst({ include: { m2: { select: { _count: true } } } }); + console.log(t); + + await expect(db.m1.findFirst({ include: { m2: { select: { _count: true } } } })).resolves.toEqual( + expect.objectContaining({ m2: [{ _count: { m3: 1 } }] }) + ); + await expect( + db.m1.findFirst({ include: { m2: { select: { _count: { select: { m3: true } } } } } }) + ).resolves.toEqual(expect.objectContaining({ m2: [{ _count: { m3: 1 } }] })); + await expect( + db.m1.findFirst({ + include: { m2: { select: { _count: { select: { m3: { where: { value: { gt: 1 } } } } } } } }, + }) + ).resolves.toEqual(expect.objectContaining({ m2: [{ _count: { m3: 0 } }] })); + }); }); diff --git a/tests/integration/tests/with-policy/relation-to-one-filter.test.ts b/tests/integration/tests/with-policy/relation-one-to-one-filter.test.ts similarity index 99% rename from tests/integration/tests/with-policy/relation-to-one-filter.test.ts rename to tests/integration/tests/with-policy/relation-one-to-one-filter.test.ts index d305a33d4..e77b27792 100644 --- a/tests/integration/tests/with-policy/relation-to-one-filter.test.ts +++ b/tests/integration/tests/with-policy/relation-one-to-one-filter.test.ts @@ -1,7 +1,7 @@ import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; -describe('With Policy: relation to-one filter', () => { +describe('With Policy: relation one-to-one filter', () => { let origDir: string; beforeAll(async () => {