diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index cedadb5cd..5c0189124 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -345,8 +345,11 @@ export class PolicyUtil { } if (injected.where && Object.keys(injected.where).length > 0 && !this.isTrue(injected.where)) { - args.where = args.where ?? {}; - Object.assign(args.where, injected.where); + if (!args.where) { + args.where = injected.where; + } else { + this.mergeWhereClause(args.where, injected.where); + } } // recursively inject read guard conditions into nested select, include, and _count @@ -355,8 +358,11 @@ export class PolicyUtil { // the injection process may generate conditions that need to be hoisted to the toplevel, // if so, merge it with the existing where if (hoistedConditions.length > 0) { - args.where = args.where ?? {}; - Object.assign(args.where, ...hoistedConditions); + if (!args.where) { + args.where = this.and(...hoistedConditions); + } else { + this.mergeWhereClause(args.where, this.and(...hoistedConditions)); + } } return true; @@ -800,5 +806,32 @@ export class PolicyUtil { return Object.assign({}, ...idFields.map((f) => ({ [f.name]: true }))); } + private mergeWhereClause(where: any, extra: any) { + if (!where) { + throw new Error('invalid where clause'); + } + + extra = this.reduce(extra); + if (this.isTrue(extra)) { + return; + } + + // instead of simply wrapping with AND, we preserve the structure + // of the original where clause and merge `extra` into it so that + // unique query can continue working + if (where.AND) { + // merge into existing AND clause + const conditions = Array.isArray(where.AND) ? [...where.AND] : [where.AND]; + conditions.push(extra); + const combined: any = this.and(...conditions); + + // make sure the merging always goes under AND + where.AND = combined.AND ?? combined; + } else { + // insert an AND clause + where.AND = [extra]; + } + } + //#endregion } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea10d647f..84e1aa949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,7 +121,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -192,7 +192,7 @@ importers: version: 2.0.3(react@18.2.0) ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) + version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) typescript: specifier: ^4.9.4 version: 4.9.4 @@ -10782,7 +10782,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10813,7 +10813,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.5 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true @@ -10852,6 +10852,41 @@ packages: yargs-parser: 21.1.1 dev: true + /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.22.9 + bs-logger: 0.2.6 + esbuild: 0.18.13 + fast-json-stable-stringify: 2.1.0 + jest: 29.5.0(@types/node@18.0.0) + jest-util: 29.5.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.3 + typescript: 4.9.5 + yargs-parser: 21.1.1 + dev: true + /ts-morph@16.0.0: resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} dependencies: diff --git a/tests/integration/tests/regression/issues.test.ts b/tests/integration/tests/regression/issues.test.ts index f94ec39a4..9f1a6d979 100644 --- a/tests/integration/tests/regression/issues.test.ts +++ b/tests/integration/tests/regression/issues.test.ts @@ -627,4 +627,210 @@ model TwoEnumsOneModelTest { await dropPostgresDb('issue-632'); } }); + + it('issue 634', async () => { + const { prisma, withPolicy } = await loadSchema( + ` +model User { + id String @id @default(uuid()) + email String @unique + password String? @password @omit + name String? + orgs Organization[] + posts Post[] + groups Group[] + comments Comment[] + // can be created by anyone, even not logged in + @@allow('create', true) + // can be read by users in the same organization + @@allow('read', orgs?[members?[auth() == this]]) + // full access by oneself + @@allow('all', auth() == this) +} + +model Organization { + id String @id @default(uuid()) + name String + members User[] + post Post[] + groups Group[] + comments Comment[] + + // everyone can create a organization + @@allow('create', true) + // any user in the organization can read the organization + @@allow('read', members?[auth() == this]) +} + +abstract model organizationBaseEntity { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isDeleted Boolean @default(false) @omit + isPublic Boolean @default(false) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId String + groups Group[] + + // when create, owner must be set to current user, and user must be in the organization + @@allow('create', owner == auth() && org.members?[this == auth()]) + // only the owner can update it and is not allowed to change the owner + @@allow('update', owner == auth() && org.members?[this == auth()] && future().owner == owner) + // allow owner to read + @@allow('read', owner == auth()) + // allow shared group members to read it + @@allow('read', groups?[users?[this == auth()]]) + // allow organization to access if public + @@allow('read', isPublic && org.members?[this == auth()]) + // can not be read if deleted + @@deny('all', isDeleted == true) +} + +model Post extends organizationBaseEntity { + title String + content String + comments Comment[] +} + +model Comment extends organizationBaseEntity { + content String + post Post @relation(fields: [postId], references: [id]) + postId String +} + +model Group { + id String @id @default(uuid()) + name String + users User[] + posts Post[] + comments Comment[] + org Organization @relation(fields: [orgId], references: [id]) + orgId String + + // group is shared by organization + @@allow('all', org.members?[auth() == this]) +} +` + ); + + const userData = [ + { + id: 'robin@prisma.io', + name: 'Robin', + email: 'robin@prisma.io', + orgs: { + create: [ + { + id: 'prisma', + name: 'prisma', + }, + ], + }, + groups: { + create: [ + { + id: 'community', + name: 'community', + orgId: 'prisma', + }, + ], + }, + posts: { + create: [ + { + id: 'slack', + title: 'Join the Prisma Slack', + content: 'https://slack.prisma.io', + orgId: 'prisma', + comments: { + create: [ + { + id: 'comment-1', + content: 'This is the first comment', + orgId: 'prisma', + ownerId: 'robin@prisma.io', + }, + ], + }, + }, + ], + }, + }, + { + id: 'bryan@prisma.io', + name: 'Bryan', + email: 'bryan@prisma.io', + orgs: { + connect: { + id: 'prisma', + }, + }, + posts: { + create: [ + { + id: 'discord', + title: 'Join the Prisma Discord', + content: 'https://discord.gg/jS3XY7vp46', + orgId: 'prisma', + groups: { + connect: { + id: 'community', + }, + }, + }, + ], + }, + }, + ]; + + for (const u of userData) { + const user = await prisma.user.create({ + data: u, + }); + console.log(`Created user with id: ${user.id}`); + } + + const db = withPolicy({ id: 'robin@prisma.io' }); + await expect( + db.comment.findMany({ + where: { + owner: { + name: 'Bryan', + }, + }, + select: { + id: true, + content: true, + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }) + ).resolves.toHaveLength(0); + + await expect( + db.comment.findMany({ + where: { + owner: { + name: 'Robin', + }, + }, + select: { + id: true, + content: true, + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }) + ).resolves.toHaveLength(1); + }); });