diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index b76875d28..f5551b309 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -275,6 +275,26 @@ export class PolicyUtil extends QueryUtils { return this.reduce(r); } + /** + * Get field-level read auth guard + */ + getFieldReadAuthGuard(db: CrudContract, model: string, field: string) { + const def = this.getModelPolicyDef(model); + const guard = def.fieldLevel?.read?.[field]?.guard; + + if (guard === undefined) { + // field access is allowed by default + return this.makeTrue(); + } + + if (typeof guard === 'boolean') { + return this.reduce(guard); + } + + const r = guard({ user: this.user }, db); + return this.reduce(r); + } + /** * Get field-level read auth guard that overrides the model-level */ @@ -419,57 +439,73 @@ export class PolicyUtil extends QueryUtils { return false; } + let mergedGuard = guard; if (args.where) { // inject into relation fields: // to-many: some/none/every // to-one: direct-conditions/is/isNot - this.injectGuardForRelationFields(db, model, args.where, operation); + mergedGuard = this.injectReadGuardForRelationFields(db, model, args.where, guard); } - args.where = this.and(args.where, guard); + args.where = this.and(args.where, mergedGuard); return true; } - private injectGuardForRelationFields( - db: CrudContract, - model: string, - payload: any, - operation: PolicyOperationKind - ) { + // Injects guard for relation fields nested in `payload`. The `modelGuard` parameter represents the model-level guard for `model`. + // The function returns a modified copy of `modelGuard` with field-level policies combined. + private injectReadGuardForRelationFields(db: CrudContract, model: string, payload: any, modelGuard: any) { + if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { + return modelGuard; + } + + const allFieldGuards: object[] = []; + const allFieldOverrideGuards: object[] = []; + for (const [field, subPayload] of Object.entries(payload)) { if (!subPayload) { continue; } - const fieldInfo = resolveField(this.modelMeta, model, field); - if (!fieldInfo || !fieldInfo.isDataModel) { - continue; - } + allFieldGuards.push(this.getFieldReadAuthGuard(db, model, field)); + allFieldOverrideGuards.push(this.getFieldOverrideReadAuthGuard(db, model, field)); - if (fieldInfo.isArray) { - this.injectGuardForToManyField(db, fieldInfo, subPayload, operation); - } else { - this.injectGuardForToOneField(db, fieldInfo, subPayload, operation); + const fieldInfo = resolveField(this.modelMeta, model, field); + if (fieldInfo?.isDataModel) { + if (fieldInfo.isArray) { + this.injectReadGuardForToManyField(db, fieldInfo, subPayload); + } else { + this.injectReadGuardForToOneField(db, fieldInfo, subPayload); + } } } + + // all existing field-level guards must be true + const mergedGuard: object = this.and(...allFieldGuards); + + // all existing field-level override guards must be true for override to take effect; override is disabled by default + const mergedOverrideGuard: object = + allFieldOverrideGuards.length === 0 ? this.makeFalse() : this.and(...allFieldOverrideGuards); + + // (original-guard && field-level-guard) || field-level-override-guard + const updatedGuard = this.or(this.and(modelGuard, mergedGuard), mergedOverrideGuard); + return updatedGuard; } - private injectGuardForToManyField( + private injectReadGuardForToManyField( db: CrudContract, fieldInfo: FieldInfo, - payload: { some?: any; every?: any; none?: any }, - operation: PolicyOperationKind + payload: { some?: any; every?: any; none?: any } ) { - const guard = this.getAuthGuard(db, fieldInfo.type, operation); + const guard = this.getAuthGuard(db, fieldInfo.type, 'read'); if (payload.some) { - this.injectGuardForRelationFields(db, fieldInfo.type, payload.some, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.some, guard); // turn "some" into: { some: { AND: [guard, payload.some] } } - payload.some = this.and(payload.some, guard); + payload.some = this.and(payload.some, mergedGuard); } if (payload.none) { - this.injectGuardForRelationFields(db, fieldInfo.type, payload.none, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.none, guard); // turn none into: { none: { AND: [guard, payload.none] } } - payload.none = this.and(payload.none, guard); + payload.none = this.and(payload.none, mergedGuard); } if ( payload.every && @@ -477,40 +513,44 @@ export class PolicyUtil extends QueryUtils { // ignore empty every clause Object.keys(payload.every).length > 0 ) { - this.injectGuardForRelationFields(db, fieldInfo.type, payload.every, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.every, guard); // turn "every" into: { none: { AND: [guard, { NOT: payload.every }] } } if (!payload.none) { payload.none = {}; } - payload.none = this.and(payload.none, guard, this.not(payload.every)); + payload.none = this.and(payload.none, mergedGuard, this.not(payload.every)); delete payload.every; } } - private injectGuardForToOneField( + private injectReadGuardForToOneField( db: CrudContract, fieldInfo: FieldInfo, - payload: { is?: any; isNot?: any } & Record, - operation: PolicyOperationKind + payload: { is?: any; isNot?: any } & Record ) { - const guard = this.getAuthGuard(db, fieldInfo.type, operation); + const guard = this.getAuthGuard(db, fieldInfo.type, 'read'); // is|isNot and flat fields conditions are mutually exclusive - if (payload.is || payload.isNot) { + // is and isNot can be null value + + if (payload.is !== undefined || payload.isNot !== undefined) { if (payload.is) { - this.injectGuardForRelationFields(db, fieldInfo.type, payload.is, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.is, guard); + // merge guard with existing "is": { is: { AND: [originalIs, guard] } } + payload.is = this.and(payload.is, mergedGuard); } + if (payload.isNot) { - this.injectGuardForRelationFields(db, fieldInfo.type, payload.isNot, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.isNot, guard); + // merge guard with existing "isNot": { isNot: { AND: [originalIsNot, guard] } } + payload.isNot = this.and(payload.isNot, mergedGuard); } - // merge guard with existing "is": { is: [originalIs, guard] } - payload.is = this.and(payload.is, guard); } else { - this.injectGuardForRelationFields(db, fieldInfo.type, payload, operation); + const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload, guard); // turn direct conditions into: { is: { AND: [ originalConditions, guard ] } } - const combined = this.and(deepcopy(payload), guard); + const combined = this.and(deepcopy(payload), mergedGuard); Object.keys(payload).forEach((key) => delete payload[key]); payload.is = combined; } @@ -530,7 +570,7 @@ export class PolicyUtil extends QueryUtils { // inject into relation fields: // to-many: some/none/every // to-one: direct-conditions/is/isNot - this.injectGuardForRelationFields(db, model, args.where, 'read'); + this.injectReadGuardForRelationFields(db, model, args.where, {}); } if (injected.where && Object.keys(injected.where).length > 0 && !this.isTrue(injected.where)) { diff --git a/packages/runtime/src/enhancements/types.ts b/packages/runtime/src/enhancements/types.ts index 8aefcd8ed..a5cd85314 100644 --- a/packages/runtime/src/enhancements/types.ts +++ b/packages/runtime/src/enhancements/types.ts @@ -214,6 +214,11 @@ type FieldCrudDef = { }; type FieldReadDef = { + /** + * Field-level Prisma query guard + */ + guard?: PolicyFunc; + /** * Entity checker */ diff --git a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts index 0f65c76c0..ce672dcc7 100644 --- a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts @@ -517,20 +517,25 @@ export class PolicyGenerator { writer.writeLine('read:'); writer.block(() => { for (const field of model.fields) { - const policyAttrs = field.attributes.filter((attr) => ['@allow', '@deny'].includes(attr.decl.$refText)); + const allows = getPolicyExpressions(field, 'allow', 'read'); + const denies = getPolicyExpressions(field, 'deny', 'read'); + const overrideAllows = getPolicyExpressions(field, 'allow', 'read', true); - if (policyAttrs.length === 0) { + if (allows.length === 0 && denies.length === 0 && overrideAllows.length === 0) { continue; } writer.write(`${field.name}:`); writer.block(() => { + // guard + const guardFunc = generateQueryGuardFunction(sourceFile, model, 'read', allows, denies, field); + writer.write(`guard: ${guardFunc.getName()},`); + // checker function // write all field-level rules as entity checker function this.writeEntityChecker(field, 'read', writer, sourceFile, false, false); - const overrideAllows = getPolicyExpressions(field, 'allow', 'read', true); if (overrideAllows.length > 0) { // override guard function const denies = getPolicyExpressions(field, 'deny', 'read'); @@ -578,7 +583,6 @@ export class PolicyGenerator { // because they cannot be checked inside Prisma this.writeEntityChecker(field, 'update', writer, sourceFile, true, false); - const overrideAllows = getPolicyExpressions(field, 'allow', 'update', true); if (overrideAllows.length > 0) { // override guard const overrideGuardFunc = generateQueryGuardFunction( diff --git a/tests/integration/tests/enhancements/with-policy/relation-one-to-many-filter.test.ts b/tests/integration/tests/enhancements/with-policy/relation-one-to-many-filter.test.ts index 1a1c40406..450726b87 100644 --- a/tests/integration/tests/enhancements/with-policy/relation-one-to-many-filter.test.ts +++ b/tests/integration/tests/enhancements/with-policy/relation-one-to-many-filter.test.ts @@ -1,17 +1,6 @@ import { loadSchema } from '@zenstackhq/testtools'; -import path from 'path'; - -describe('With Policy: relation one-to-many filter', () => { - let origDir: string; - - beforeAll(async () => { - origDir = path.resolve('.'); - }); - - afterEach(() => { - process.chdir(origDir); - }); +describe('Relation one-to-many filter', () => { const model = ` model M1 { id String @id @default(uuid()) @@ -456,3 +445,582 @@ describe('With Policy: relation one-to-many filter', () => { ).resolves.toMatchObject({ m2: [{ _count: { m3: 0 } }] }); }); }); + +describe('Relation one-to-many filter with field-level rules', () => { + const model = ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int @allow('read', !deleted) + deleted Boolean @default(false) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String + m3 M3[] + + @@allow('read', true) + @@allow('create', true) + } + + model M3 { + id String @id @default(uuid()) + value Int @deny('read', deleted) + deleted Boolean @default(false) + m2 M2 @relation(fields: [m2Id], references:[id]) + m2Id String + + @@allow('read', true) + @@allow('create', true) + } + `; + + it('some filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + m3: { + create: { + id: '3-1', + value: 1, + }, + }, + }, + { + id: '2-2', + value: 2, + deleted: true, + m3: { + create: { + id: '3-2', + value: 2, + deleted: true, + }, + }, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: { id: '2-2' }, + }, + }, + }) + ).toResolveTruthy(); + + // include clause + + const r = await db.m1.findFirst({ + where: { id: '1' }, + include: { + m2: { + where: { + m3: { + some: {}, + }, + }, + }, + }, + }); + expect(r.m2).toHaveLength(2); + + let r1 = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + some: { value: { gt: 1 } }, + }, + }, + }, + }, + }); + expect(r1.m2).toHaveLength(0); + + r1 = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + some: { id: { equals: '3-2' } }, + }, + }, + }, + }, + }); + expect(r1.m2).toHaveLength(1); + }); + + it('none filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + m3: { + create: { + id: '3-1', + value: 1, + }, + }, + }, + { + id: '2-2', + value: 2, + deleted: true, + m3: { + create: { + id: '3-2', + value: 2, + deleted: true, + }, + }, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: {}, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: { id: '2-1' }, + }, + }, + }) + ).toResolveFalsy(); + + // include clause + + let r = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + none: { value: { gt: 1 } }, + }, + }, + }, + }, + }); + expect(r.m2).toHaveLength(2); + + r = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + none: { id: { equals: '3-2' } }, + }, + }, + }, + }, + }); + expect(r.m2).toHaveLength(1); + }); + + it('every filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + m3: { + create: { + id: '3-1', + value: 1, + }, + }, + }, + { + id: '2-2', + value: 2, + deleted: true, + m3: { + create: { + id: '3-2', + value: 2, + deleted: true, + }, + }, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: { id: { contains: '2' } }, + }, + }, + }) + ).toResolveTruthy(); + + // include clause + + const r = await db.m1.findFirst({ + where: { id: '1' }, + include: { + m2: { + where: { + m3: { + every: {}, + }, + }, + }, + }, + }); + expect(r.m2).toHaveLength(2); + + let r1 = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + every: { value: { gt: 1 } }, + }, + }, + }, + }, + }); + expect(r1.m2).toHaveLength(1); + + r1 = await db.m1.findFirst({ + where: { + id: '1', + }, + include: { + m2: { + where: { + m3: { + every: { id: { contains: '3' } }, + }, + }, + }, + }, + }); + expect(r1.m2).toHaveLength(2); + }); +}); + +describe('Relation one-to-many filter with field-level override rules', () => { + const model = ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) @allow('read', true, true) + value Int @allow('read', !deleted) + deleted Boolean @default(false) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String + + @@allow('read', !deleted) + @@allow('create', true) + } + `; + + it('some filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + }, + { + id: '2-2', + value: 2, + deleted: true, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + some: { id: '2-2' }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('none filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + }, + { + id: '2-2', + value: 2, + deleted: true, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: {}, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + none: { id: '2-1' }, + }, + }, + }) + ).toResolveFalsy(); + }); + + it('every filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: [ + { + id: '2-1', + value: 1, + }, + { + id: '2-2', + value: 2, + deleted: true, + }, + ], + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: {}, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: { value: { gt: 1 } }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + every: { id: { contains: '2' } }, + }, + }, + }) + ).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts b/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts index d076e18e5..1f8666fd5 100644 --- a/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts +++ b/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts @@ -1,17 +1,6 @@ import { loadSchema } from '@zenstackhq/testtools'; -import path from 'path'; - -describe('With Policy: relation one-to-one filter', () => { - let origDir: string; - - beforeAll(async () => { - origDir = path.resolve('.'); - }); - - afterEach(() => { - process.chdir(origDir); - }); +describe('Relation one-to-one filter', () => { const model = ` model M1 { id String @id @default(uuid()) @@ -184,6 +173,17 @@ describe('With Policy: relation one-to-one filter', () => { }) ).toResolveTruthy(); + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + // m1 with m2 await db.m1.create({ data: { @@ -206,7 +206,18 @@ describe('With Policy: relation one-to-one filter', () => { }, }, }) - ).toResolveFalsy(); + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveTruthy(); // m1 with m2 and m3 await db.m1.create({ @@ -239,7 +250,22 @@ describe('With Policy: relation one-to-one filter', () => { }, }, }) - ).toResolveTruthy(); + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + isNot: { + m3: { + isNot: { value: 1 }, + }, + }, + }, + }, + }) + ).toResolveFalsy(); // m1 with null m2 await db.m1.create({ @@ -257,7 +283,7 @@ describe('With Policy: relation one-to-one filter', () => { }, }, }) - ).toResolveFalsy(); + ).toResolveTruthy(); }); it('direct object filter', async () => { @@ -365,3 +391,721 @@ describe('With Policy: relation one-to-one filter', () => { ).toResolveFalsy(); }); }); + +describe('Relation one-to-one filter with field-level rules', () => { + const model = ` + model M1 { + id String @id @default(uuid()) + m2 M2? + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int @allow('read', !deleted) + deleted Boolean @default(false) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String @unique + m3 M3? + + @@allow('read', true) + @@allow('create', true) + } + + model M3 { + id String @id @default(uuid()) + value Int @allow('read', !deleted) + deleted Boolean @default(false) + m2 M2 @relation(fields: [m2Id], references:[id]) + m2Id String @unique + + @@allow('read', true) + @@allow('create', true) + } + `; + + it('is filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + id: '1', + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + is: { value: 1 }, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + is: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + is: { id: '2' }, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '3', + m2: { + create: { + id: '3', + value: 1, + m3: { + create: { + id: '3', + value: 1, + deleted: true, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + is: { + m3: { value: 1 }, + }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + is: { + m3: { id: '3' }, + }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('isNot filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + id: '1', + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + isNot: { value: 0 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { value: 0 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { id: '2' }, + }, + }, + }) + ).toResolveFalsy(); + }); + + it('direct object filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + value: 1, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + value: 1, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + id: '2', + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '3', + m2: { + create: { + id: '3', + value: 1, + m3: { + create: { + id: '3', + value: 1, + deleted: true, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + m3: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + m3: { id: '3' }, + }, + }, + }) + ).toResolveTruthy(); + }); +}); + +describe('Relation one-to-one filter with field-level override rules', () => { + const model = ` + model M1 { + id String @id @default(uuid()) + m2 M2? + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) @allow('read', true, true) + value Int + deleted Boolean @default(false) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String @unique + m3 M3? + + @@allow('read', !deleted) + @@allow('create', true) + } + + model M3 { + id String @id @default(uuid()) @allow('read', true, true) + value Int + deleted Boolean @default(false) + m2 M2 @relation(fields: [m2Id], references:[id]) + m2Id String @unique + + @@allow('read', !deleted) + @@allow('create', true) + } + `; + + it('is filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + id: '1', + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + is: { value: 1 }, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + is: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + is: { id: '2' }, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '3', + m2: { + create: { + id: '3', + value: 1, + m3: { + create: { + id: '3', + value: 1, + deleted: true, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + is: { + m3: { value: 1 }, + }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + is: { + m3: { id: '3' }, + }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('isNot filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + id: '1', + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + isNot: { value: 0 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { value: 0 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { value: 1 }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + isNot: { id: '2' }, + }, + }, + }) + ).toResolveFalsy(); + }); + + it('direct object filter', async () => { + const { enhance } = await loadSchema(model); + + const db = enhance(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '1', + m2: { + create: { + id: '1', + value: 1, + m3: { + create: { + value: 1, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '1', + m2: { + value: 1, + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 + await db.m1.create({ + data: { + id: '2', + m2: { + create: { + id: '2', + value: 1, + deleted: true, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + value: 1, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '2', + m2: { + id: '2', + }, + }, + }) + ).toResolveTruthy(); + + // m1 with m2 and m3 + await db.m1.create({ + data: { + id: '3', + m2: { + create: { + id: '3', + value: 1, + m3: { + create: { + id: '3', + value: 1, + deleted: true, + }, + }, + }, + }, + }, + }); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + m3: { value: 1 }, + }, + }, + }) + ).toResolveFalsy(); + + await expect( + db.m1.findFirst({ + where: { + id: '3', + m2: { + m3: { id: '3' }, + }, + }, + }) + ).toResolveTruthy(); + }); +}); diff --git a/tests/regression/tests/issue-1454.test.ts b/tests/regression/tests/issue-1454.test.ts new file mode 100644 index 000000000..6c42fcf59 --- /dev/null +++ b/tests/regression/tests/issue-1454.test.ts @@ -0,0 +1,117 @@ +import { loadSchema } from '@zenstackhq/testtools'; +describe('issue 1454', () => { + it('regression1', async () => { + const { prisma, enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + sensitiveInformation String + username String + + purchases Purchase[] + + @@allow('read', auth() == this) + } + + model Purchase { + id Int @id @default(autoincrement()) + purchasedAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@allow('read', true) + } + ` + ); + + const db = enhance(); + await prisma.user.create({ + data: { username: 'user1', sensitiveInformation: 'sensitive', purchases: { create: {} } }, + }); + + await expect(db.purchase.findMany({ where: { user: { username: 'user1' } } })).resolves.toHaveLength(0); + await expect(db.purchase.findMany({ where: { user: { is: { username: 'user1' } } } })).resolves.toHaveLength(0); + }); + + it('regression2', async () => { + const { prisma, enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + username String @allow('read', false) + + purchases Purchase[] + + @@allow('read', true) + } + + model Purchase { + id Int @id @default(autoincrement()) + purchasedAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@allow('read', true) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + const user = await prisma.user.create({ + data: { username: 'user1', purchases: { create: {} } }, + }); + + await expect(db.purchase.findMany({ where: { user: { id: user.id } } })).resolves.toHaveLength(1); + await expect(db.purchase.findMany({ where: { user: { username: 'user1' } } })).resolves.toHaveLength(0); + await expect(db.purchase.findMany({ where: { user: { is: { username: 'user1' } } } })).resolves.toHaveLength(0); + }); + + it('regression3', async () => { + const { prisma, enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + sensitiveInformation String + username String @allow('read', true, true) + + purchases Purchase[] + + @@allow('read', auth() == this) + } + + model Purchase { + id Int @id @default(autoincrement()) + purchasedAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@allow('read', true) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + await prisma.user.create({ + data: { username: 'user1', sensitiveInformation: 'sensitive', purchases: { create: {} } }, + }); + + await expect(db.purchase.findMany({ where: { user: { username: 'user1' } } })).resolves.toHaveLength(1); + await expect(db.purchase.findMany({ where: { user: { is: { username: 'user1' } } } })).resolves.toHaveLength(1); + await expect( + db.purchase.findMany({ where: { user: { sensitiveInformation: 'sensitive' } } }) + ).resolves.toHaveLength(0); + await expect( + db.purchase.findMany({ where: { user: { is: { sensitiveInformation: 'sensitive' } } } }) + ).resolves.toHaveLength(0); + await expect( + db.purchase.findMany({ where: { user: { username: 'user1', sensitiveInformation: 'sensitive' } } }) + ).resolves.toHaveLength(0); + await expect( + db.purchase.findMany({ + where: { OR: [{ user: { username: 'user1' } }, { user: { sensitiveInformation: 'sensitive' } }] }, + }) + ).resolves.toHaveLength(1); + }); +});