diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 7a81d5286..43d5ba665 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -680,8 +680,18 @@ export class PolicyProxyHandler implements Pr if (context.field?.backLink) { const backLinkField = this.utils.getModelField(model, context.field.backLink); if (backLinkField.isRelationOwner) { - // update happens on the related model, require updatable - await this.utils.checkPolicyForUnique(model, args, 'update', db, args); + // update happens on the related model, require updatable, + // translate args to foreign keys so field-level policies can be checked + const checkArgs: any = {}; + if (args && typeof args === 'object' && backLinkField.foreignKeyMapping) { + for (const key of Object.keys(args)) { + const fk = backLinkField.foreignKeyMapping[key]; + if (fk) { + checkArgs[fk] = args[key]; + } + } + } + await this.utils.checkPolicyForUnique(model, args, 'update', db, checkArgs); // register post-update check await _registerPostUpdateCheck(model, args); diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 876845016..e448c67f0 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -763,11 +763,29 @@ export class PolicyUtil { if (typeof v === 'undefined') { continue; } - const fieldGuard = this.getFieldUpdateAuthGuard(db, model, k); - if (this.isFalse(fieldGuard)) { - return { guard: allFieldGuards, rejectedByField: k }; + + const field = resolveField(this.modelMeta, model, k); + + if (field?.isDataModel) { + // relation field update should be treated as foreign key update, + // fetch and merge all foreign key guards + if (field.isRelationOwner && field.foreignKeyMapping) { + const foreignKeys = Object.values(field.foreignKeyMapping); + for (const fk of foreignKeys) { + const fieldGuard = this.getFieldUpdateAuthGuard(db, model, fk); + if (this.isFalse(fieldGuard)) { + return { guard: allFieldGuards, rejectedByField: fk }; + } + allFieldGuards.push(fieldGuard); + } + } + } else { + const fieldGuard = this.getFieldUpdateAuthGuard(db, model, k); + if (this.isFalse(fieldGuard)) { + return { guard: allFieldGuards, rejectedByField: k }; + } + allFieldGuards.push(fieldGuard); } - allFieldGuards.push(fieldGuard); } return { guard: this.and(...allFieldGuards), rejectedByField: undefined }; } diff --git a/packages/runtime/src/version.ts b/packages/runtime/src/version.ts index fb25b4b51..567ef7a71 100644 --- a/packages/runtime/src/version.ts +++ b/packages/runtime/src/version.ts @@ -19,6 +19,15 @@ export function getVersion() { * "prisma". */ export function getPrismaVersion(): string | undefined { + if (process.env.ZENSTACK_TEST === '1') { + // test environment + try { + return require(path.resolve('./node_modules/@prisma/client/package.json')).version; + } catch { + return undefined; + } + } + try { // eslint-disable-next-line @typescript-eslint/no-var-requires return require('@prisma/client/package.json').version; @@ -27,15 +36,6 @@ export function getPrismaVersion(): string | undefined { // eslint-disable-next-line @typescript-eslint/no-var-requires return require('prisma/package.json').version; } catch { - if (process.env.ZENSTACK_TEST === '1') { - // test environment - try { - return require(path.resolve('./node_modules/@prisma/client/package.json')).version; - } catch { - return undefined; - } - } - return undefined; } } diff --git a/packages/schema/src/language-server/validator/attribute-application-validator.ts b/packages/schema/src/language-server/validator/attribute-application-validator.ts index e25563cd2..ea202f7f5 100644 --- a/packages/schema/src/language-server/validator/attribute-application-validator.ts +++ b/packages/schema/src/language-server/validator/attribute-application-validator.ts @@ -15,7 +15,7 @@ import { isEnum, isReferenceExpr, } from '@zenstackhq/language/ast'; -import { isFutureExpr, resolved } from '@zenstackhq/sdk'; +import { isFutureExpr, isRelationshipField, resolved } from '@zenstackhq/sdk'; import { ValidationAcceptor, streamAst } from 'langium'; import pluralize from 'pluralize'; import { AstValidator } from '../types'; @@ -131,12 +131,24 @@ export default class AttributeApplicationValidator implements AstValidator isFutureExpr(node))) { accept('error', `"future()" is not allowed in field-level policy rules`, { node: expr }); } + + // 'update' rules are not allowed for relation fields + if (kindItems.includes('update') || kindItems.includes('all')) { + const field = attr.$container as DataModelField; + if (isRelationshipField(field)) { + accept( + 'error', + `Field-level policy rules with "update" or "all" kind are not allowed for relation fields. Put rules on foreign-key fields instead.`, + { node: attr } + ); + } + } } private validatePolicyKinds( @@ -155,6 +167,7 @@ export default class AttributeApplicationValidator implements AstValidator { } `) ).toContain('"future()" is not allowed in field-level policy rules'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + n N @allow('update', n.x > 0) + } + + model N { + id String @id + x Int + m M? @relation(fields: [mId], references: [id]) + mId String + } + `) + ).toContain( + 'Field-level policy rules with "update" or "all" kind are not allowed for relation fields. Put rules on foreign-key fields instead.' + ); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + n N[] @allow('update', n.x > 0) + } + + model N { + id String @id + x Int + m M? @relation(fields: [mId], references: [id]) + mId String + } + `) + ).toContain( + 'Field-level policy rules with "update" or "all" kind are not allowed for relation fields. Put rules on foreign-key fields instead.' + ); }); }); diff --git a/tests/integration/tests/enhancements/with-policy/field-level-policy.test.ts b/tests/integration/tests/enhancements/with-policy/field-level-policy.test.ts index d8fa93872..87f1579c9 100644 --- a/tests/integration/tests/enhancements/with-policy/field-level-policy.test.ts +++ b/tests/integration/tests/enhancements/with-policy/field-level-policy.test.ts @@ -313,7 +313,7 @@ describe('With Policy: field-level policy', () => { }); it('read coverage', async () => { - const { prisma, withPolicy } = await loadSchema( + const { withPolicy } = await loadSchema( ` model Model { id Int @id @default(autoincrement()) @@ -361,6 +361,74 @@ describe('With Policy: field-level policy', () => { expect(r[1].y).toEqual(0); }); + it('read relation', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + admin Boolean @default(false) + posts Post[] @allow('read', admin) + + @@allow('all', true) + } + + model Post { + id Int @id @default(autoincrement()) + author User? @relation(fields: [authorId], references: [id]) @allow('read', author.admin) + authorId Int @allow('read', author.admin) + title String + published Boolean @default(false) + + @@allow('all', true) + } + ` + ); + + await prisma.user.create({ + data: { + id: 1, + admin: false, + posts: { + create: [{ id: 1, title: 'post1' }], + }, + }, + }); + + await prisma.user.create({ + data: { + id: 2, + admin: true, + posts: { + create: [{ id: 2, title: 'post2' }], + }, + }, + }); + + const db = withPolicy(); + + // read to-many relation + let r = await db.user.findUnique({ + where: { id: 1 }, + include: { posts: true }, + }); + expect(r.posts).toBeUndefined(); + r = await db.user.findUnique({ + where: { id: 2 }, + include: { posts: true }, + }); + expect(r.posts).toHaveLength(1); + + // read to-one relation + r = await db.post.findUnique({ where: { id: 1 }, include: { author: true } }); + expect(r.author).toBeUndefined(); + expect(r.authorId).toBeUndefined(); + r = await db.post.findUnique({ where: { id: 1 }, select: { author: { select: { admin: true } } } }); + expect(r.author).toBeUndefined(); + r = await db.post.findUnique({ where: { id: 2 }, include: { author: true } }); + expect(r.author).toBeTruthy(); + expect(r.authorId).toBeTruthy(); + }); + it('update simple', async () => { const { prisma, withPolicy } = await loadSchema( ` @@ -490,7 +558,7 @@ describe('With Policy: field-level policy', () => { ).toResolveTruthy(); }); - it('update to-many relation', async () => { + it('update with nested to-many relation', async () => { const { prisma, withPolicy } = await loadSchema( ` model User { @@ -542,7 +610,7 @@ describe('With Policy: field-level policy', () => { ).toResolveTruthy(); }); - it('update to-one relation', async () => { + it('update with nested to-one relation', async () => { const { prisma, withPolicy } = await loadSchema( ` model User { @@ -612,6 +680,188 @@ describe('With Policy: field-level policy', () => { ).toResolveTruthy(); }); + it('update with connect to-many relation', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + models Model[] + admin Boolean @default(false) + + @@allow('all', true) + } + + model Model { + id Int @id @default(autoincrement()) + value Int + owner User? @relation(fields: [ownerId], references: [id]) + ownerId Int? @allow('update', value > 0) + + @@allow('all', true) + } + ` + ); + + await prisma.user.create({ data: { id: 1, admin: false } }); + await prisma.user.create({ data: { id: 2, admin: true } }); + await prisma.model.create({ data: { id: 1, value: 0 } }); + await prisma.model.create({ data: { id: 2, value: 1 } }); + + const db = withPolicy(); + + await expect( + db.model.update({ + where: { id: 1 }, + data: { owner: { connect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.model.update({ + where: { id: 1 }, + data: { owner: { disconnect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.model.update({ + where: { id: 2 }, + data: { owner: { connect: { id: 1 } } }, + }) + ).toResolveTruthy(); + await expect( + db.model.update({ + where: { id: 2 }, + data: { owner: { disconnect: { id: 1 } } }, + }) + ).toResolveTruthy(); + + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { connect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { disconnect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { set: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { connect: { id: 2 } } }, + }) + ).toResolveTruthy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { disconnect: { id: 2 } } }, + }) + ).toResolveTruthy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { models: { set: { id: 2 } } }, + }) + ).toResolveTruthy(); + }); + + it('update with connect to-one relation', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + model Model? + admin Boolean @default(false) + + @@allow('all', true) + } + + model Model { + id Int @id @default(autoincrement()) + value Int + owner User? @relation(fields: [ownerId], references: [id]) + ownerId Int? @unique @allow('update', value > 0) + + @@allow('all', true) + } + ` + ); + + await prisma.user.create({ data: { id: 1, admin: false } }); + await prisma.user.create({ data: { id: 2, admin: true } }); + await prisma.model.create({ data: { id: 1, value: 0 } }); + await prisma.model.create({ data: { id: 2, value: 1 } }); + + const db = withPolicy(); + + await expect( + db.model.update({ + where: { id: 1 }, + data: { owner: { connect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.model.update({ + where: { id: 1 }, + data: { owner: { disconnect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.model.update({ + where: { id: 1 }, + data: { owner: { set: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.model.update({ + where: { id: 2 }, + data: { owner: { connect: { id: 1 } } }, + }) + ).toResolveTruthy(); + await expect( + db.model.update({ + where: { id: 2 }, + data: { owner: { disconnect: { id: 1 } } }, + }) + ).toResolveTruthy(); + + await expect( + db.user.update({ + where: { id: 1 }, + data: { model: { connect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { model: { disconnect: { id: 1 } } }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.update({ + where: { id: 1 }, + data: { model: { connect: { id: 2 } } }, + }) + ).toResolveTruthy(); + await expect( + db.user.update({ + where: { id: 1 }, + data: { model: { disconnect: { id: 2 } } }, + }) + ).toResolveTruthy(); + }); + it('updateMany simple', async () => { const { prisma, withPolicy } = await loadSchema( `