@@ -23,7 +23,7 @@ import {
2323 PolicyOperationKind ,
2424 PrismaWriteActionType ,
2525} from '../../types' ;
26- import { getVersion } from '../../version' ;
26+ import { getPrismaVersion , getVersion } from '../../version' ;
2727import { getFields , resolveField } from '../model-meta' ;
2828import { NestedWriteVisitor , type NestedWriteVisitorContext } from '../nested-write-vistor' ;
2929import type { ModelMeta , PolicyDef , PolicyFunc , ZodSchemas } from '../types' ;
@@ -36,6 +36,7 @@ import {
3636 prismaClientUnknownRequestError ,
3737} from '../utils' ;
3838import { Logger } from './logger' ;
39+ import semver from 'semver' ;
3940
4041/**
4142 * Access policy enforcement utilities
@@ -45,6 +46,8 @@ export class PolicyUtil {
4546 // @ts -ignore
4647 private readonly logger : Logger ;
4748
49+ private supportNestedToOneFilter = false ;
50+
4851 constructor (
4952 private readonly db : DbClientContract ,
5053 private readonly modelMeta : ModelMeta ,
@@ -54,6 +57,10 @@ export class PolicyUtil {
5457 private readonly logPrismaQuery ?: boolean
5558 ) {
5659 this . logger = new Logger ( db ) ;
60+
61+ // use Prisma version to detect if we can filter when nested-fetching to-one relation
62+ const prismaVersion = getPrismaVersion ( ) ;
63+ this . supportNestedToOneFilter = prismaVersion ? semver . gte ( prismaVersion , '4.8.0' ) : false ;
5764 }
5865
5966 /**
@@ -334,20 +341,29 @@ export class PolicyUtil {
334341 }
335342
336343 const idFields = this . getIdFields ( model ) ;
344+
337345 for ( const field of getModelFields ( injectTarget ) ) {
338346 const fieldInfo = resolveField ( this . modelMeta , model , field ) ;
339347 if ( ! fieldInfo || ! fieldInfo . isDataModel ) {
340348 // only care about relation fields
341349 continue ;
342350 }
343351
344- if ( fieldInfo . isArray ) {
352+ if (
353+ fieldInfo . isArray ||
354+ // if Prisma version is high enough to support filtering directly when
355+ // fetching a nullable to-one relation, let's do it that way
356+ // https://github.com/prisma/prisma/discussions/20350
357+ ( this . supportNestedToOneFilter && fieldInfo . isOptional )
358+ ) {
345359 if ( typeof injectTarget [ field ] !== 'object' ) {
346360 injectTarget [ field ] = { } ;
347361 }
348- // inject extra condition for to-many relation
349-
362+ // inject extra condition for to-many or nullable to-one relation
350363 await this . injectAuthGuard ( injectTarget [ field ] , fieldInfo . type , 'read' ) ;
364+
365+ // recurse
366+ await this . injectNestedReadConditions ( fieldInfo . type , injectTarget [ field ] ) ;
351367 } else {
352368 // there's no way of injecting condition for to-one relation, so if there's
353369 // "select" clause we make sure 'id' fields are selected and check them against
@@ -361,9 +377,6 @@ export class PolicyUtil {
361377 }
362378 }
363379 }
364-
365- // recurse
366- await this . injectNestedReadConditions ( fieldInfo . type , injectTarget [ field ] ) ;
367380 }
368381 }
369382
@@ -373,69 +386,79 @@ export class PolicyUtil {
373386 * omitted.
374387 */
375388 async postProcessForRead ( data : any , model : string , args : any , operation : PolicyOperationKind ) {
376- for ( const entityData of enumerate ( data ) ) {
377- if ( typeof entityData !== 'object' || ! entityData ) {
378- continue ;
379- }
389+ await Promise . all (
390+ enumerate ( data ) . map ( ( entityData ) => this . postProcessSingleEntityForRead ( entityData , model , args , operation ) )
391+ ) ;
392+ }
380393
381- // strip auxiliary fields
382- for ( const auxField of AUXILIARY_FIELDS ) {
383- if ( auxField in entityData ) {
384- delete entityData [ auxField ] ;
385- }
394+ private async postProcessSingleEntityForRead ( data : any , model : string , args : any , operation : PolicyOperationKind ) {
395+ if ( typeof data !== 'object' || ! data ) {
396+ return ;
397+ }
398+
399+ // strip auxiliary fields
400+ for ( const auxField of AUXILIARY_FIELDS ) {
401+ if ( auxField in data ) {
402+ delete data [ auxField ] ;
386403 }
404+ }
405+
406+ const injectTarget = args . select ?? args . include ;
407+ if ( ! injectTarget ) {
408+ return ;
409+ }
387410
388- const injectTarget = args . select ?? args . include ;
389- if ( ! injectTarget ) {
411+ // recurse into nested entities
412+ for ( const field of Object . keys ( injectTarget ) ) {
413+ const fieldData = data [ field ] ;
414+ if ( typeof fieldData !== 'object' || ! fieldData ) {
390415 continue ;
391416 }
392417
393- // recurse into nested entities
394- for ( const field of Object . keys ( injectTarget ) ) {
395- const fieldData = entityData [ field ] ;
396- if ( typeof fieldData !== 'object' || ! fieldData ) {
397- continue ;
398- }
418+ const fieldInfo = resolveField ( this . modelMeta , model , field ) ;
419+ if ( fieldInfo ) {
420+ if (
421+ fieldInfo . isDataModel &&
422+ ! fieldInfo . isArray &&
423+ // if Prisma version supports filtering nullable to-one relation, no need to further check
424+ ! ( this . supportNestedToOneFilter && fieldInfo . isOptional )
425+ ) {
426+ // to-one relation data cannot be trimmed by injected guards, we have to
427+ // post-check them
428+ const ids = this . getEntityIds ( fieldInfo . type , fieldData ) ;
429+
430+ if ( Object . keys ( ids ) . length !== 0 ) {
431+ if ( this . logger . enabled ( 'info' ) ) {
432+ this . logger . info (
433+ `Validating read of to-one relation: ${ fieldInfo . type } #${ formatObject ( ids ) } `
434+ ) ;
435+ }
399436
400- const fieldInfo = resolveField ( this . modelMeta , model , field ) ;
401- if ( fieldInfo ) {
402- if ( fieldInfo . isDataModel && ! fieldInfo . isArray ) {
403- // to-one relation data cannot be trimmed by injected guards, we have to
404- // post-check them
405- const ids = this . getEntityIds ( fieldInfo . type , fieldData ) ;
406-
407- if ( Object . keys ( ids ) . length !== 0 ) {
408- // if (this.logger.enabled('info')) {
409- // this.logger.info(
410- // `Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
411- // );
412- // }
413- try {
414- await this . checkPolicyForFilter ( fieldInfo . type , ids , operation , this . db ) ;
415- } catch ( err ) {
416- if (
417- isPrismaClientKnownRequestError ( err ) &&
418- err . code === PrismaErrorCode . CONSTRAINED_FAILED
419- ) {
420- // denied by policy
421- if ( fieldInfo . isOptional ) {
422- // if the relation is optional, just nullify it
423- entityData [ field ] = null ;
424- } else {
425- // otherwise reject
426- throw err ;
427- }
437+ try {
438+ await this . checkPolicyForFilter ( fieldInfo . type , ids , operation , this . db ) ;
439+ } catch ( err ) {
440+ if (
441+ isPrismaClientKnownRequestError ( err ) &&
442+ err . code === PrismaErrorCode . CONSTRAINED_FAILED
443+ ) {
444+ // denied by policy
445+ if ( fieldInfo . isOptional ) {
446+ // if the relation is optional, just nullify it
447+ data [ field ] = null ;
428448 } else {
429- // unknown error
449+ // otherwise reject
430450 throw err ;
431451 }
452+ } else {
453+ // unknown error
454+ throw err ;
432455 }
433456 }
434457 }
435-
436- // recurse
437- await this . postProcessForRead ( fieldData , fieldInfo . type , injectTarget [ field ] , operation ) ;
438458 }
459+
460+ // recurse
461+ await this . postProcessForRead ( fieldData , fieldInfo . type , injectTarget [ field ] , operation ) ;
439462 }
440463 }
441464 }
0 commit comments