Skip to content

Commit 54e1e02

Browse files
authored
fix(policy): relation filter should respect field-level policies (#1495)
1 parent ed5133c commit 54e1e02

File tree

6 files changed

+1547
-69
lines changed

6 files changed

+1547
-69
lines changed

packages/runtime/src/enhancements/policy/policy-utils.ts

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,26 @@ export class PolicyUtil extends QueryUtils {
275275
return this.reduce(r);
276276
}
277277

278+
/**
279+
* Get field-level read auth guard
280+
*/
281+
getFieldReadAuthGuard(db: CrudContract, model: string, field: string) {
282+
const def = this.getModelPolicyDef(model);
283+
const guard = def.fieldLevel?.read?.[field]?.guard;
284+
285+
if (guard === undefined) {
286+
// field access is allowed by default
287+
return this.makeTrue();
288+
}
289+
290+
if (typeof guard === 'boolean') {
291+
return this.reduce(guard);
292+
}
293+
294+
const r = guard({ user: this.user }, db);
295+
return this.reduce(r);
296+
}
297+
278298
/**
279299
* Get field-level read auth guard that overrides the model-level
280300
*/
@@ -419,98 +439,118 @@ export class PolicyUtil extends QueryUtils {
419439
return false;
420440
}
421441

442+
let mergedGuard = guard;
422443
if (args.where) {
423444
// inject into relation fields:
424445
// to-many: some/none/every
425446
// to-one: direct-conditions/is/isNot
426-
this.injectGuardForRelationFields(db, model, args.where, operation);
447+
mergedGuard = this.injectReadGuardForRelationFields(db, model, args.where, guard);
427448
}
428449

429-
args.where = this.and(args.where, guard);
450+
args.where = this.and(args.where, mergedGuard);
430451
return true;
431452
}
432453

433-
private injectGuardForRelationFields(
434-
db: CrudContract,
435-
model: string,
436-
payload: any,
437-
operation: PolicyOperationKind
438-
) {
454+
// Injects guard for relation fields nested in `payload`. The `modelGuard` parameter represents the model-level guard for `model`.
455+
// The function returns a modified copy of `modelGuard` with field-level policies combined.
456+
private injectReadGuardForRelationFields(db: CrudContract, model: string, payload: any, modelGuard: any) {
457+
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
458+
return modelGuard;
459+
}
460+
461+
const allFieldGuards: object[] = [];
462+
const allFieldOverrideGuards: object[] = [];
463+
439464
for (const [field, subPayload] of Object.entries<any>(payload)) {
440465
if (!subPayload) {
441466
continue;
442467
}
443468

444-
const fieldInfo = resolveField(this.modelMeta, model, field);
445-
if (!fieldInfo || !fieldInfo.isDataModel) {
446-
continue;
447-
}
469+
allFieldGuards.push(this.getFieldReadAuthGuard(db, model, field));
470+
allFieldOverrideGuards.push(this.getFieldOverrideReadAuthGuard(db, model, field));
448471

449-
if (fieldInfo.isArray) {
450-
this.injectGuardForToManyField(db, fieldInfo, subPayload, operation);
451-
} else {
452-
this.injectGuardForToOneField(db, fieldInfo, subPayload, operation);
472+
const fieldInfo = resolveField(this.modelMeta, model, field);
473+
if (fieldInfo?.isDataModel) {
474+
if (fieldInfo.isArray) {
475+
this.injectReadGuardForToManyField(db, fieldInfo, subPayload);
476+
} else {
477+
this.injectReadGuardForToOneField(db, fieldInfo, subPayload);
478+
}
453479
}
454480
}
481+
482+
// all existing field-level guards must be true
483+
const mergedGuard: object = this.and(...allFieldGuards);
484+
485+
// all existing field-level override guards must be true for override to take effect; override is disabled by default
486+
const mergedOverrideGuard: object =
487+
allFieldOverrideGuards.length === 0 ? this.makeFalse() : this.and(...allFieldOverrideGuards);
488+
489+
// (original-guard && field-level-guard) || field-level-override-guard
490+
const updatedGuard = this.or(this.and(modelGuard, mergedGuard), mergedOverrideGuard);
491+
return updatedGuard;
455492
}
456493

457-
private injectGuardForToManyField(
494+
private injectReadGuardForToManyField(
458495
db: CrudContract,
459496
fieldInfo: FieldInfo,
460-
payload: { some?: any; every?: any; none?: any },
461-
operation: PolicyOperationKind
497+
payload: { some?: any; every?: any; none?: any }
462498
) {
463-
const guard = this.getAuthGuard(db, fieldInfo.type, operation);
499+
const guard = this.getAuthGuard(db, fieldInfo.type, 'read');
464500
if (payload.some) {
465-
this.injectGuardForRelationFields(db, fieldInfo.type, payload.some, operation);
501+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.some, guard);
466502
// turn "some" into: { some: { AND: [guard, payload.some] } }
467-
payload.some = this.and(payload.some, guard);
503+
payload.some = this.and(payload.some, mergedGuard);
468504
}
469505
if (payload.none) {
470-
this.injectGuardForRelationFields(db, fieldInfo.type, payload.none, operation);
506+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.none, guard);
471507
// turn none into: { none: { AND: [guard, payload.none] } }
472-
payload.none = this.and(payload.none, guard);
508+
payload.none = this.and(payload.none, mergedGuard);
473509
}
474510
if (
475511
payload.every &&
476512
typeof payload.every === 'object' &&
477513
// ignore empty every clause
478514
Object.keys(payload.every).length > 0
479515
) {
480-
this.injectGuardForRelationFields(db, fieldInfo.type, payload.every, operation);
516+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.every, guard);
481517

482518
// turn "every" into: { none: { AND: [guard, { NOT: payload.every }] } }
483519
if (!payload.none) {
484520
payload.none = {};
485521
}
486-
payload.none = this.and(payload.none, guard, this.not(payload.every));
522+
payload.none = this.and(payload.none, mergedGuard, this.not(payload.every));
487523
delete payload.every;
488524
}
489525
}
490526

491-
private injectGuardForToOneField(
527+
private injectReadGuardForToOneField(
492528
db: CrudContract,
493529
fieldInfo: FieldInfo,
494-
payload: { is?: any; isNot?: any } & Record<string, any>,
495-
operation: PolicyOperationKind
530+
payload: { is?: any; isNot?: any } & Record<string, any>
496531
) {
497-
const guard = this.getAuthGuard(db, fieldInfo.type, operation);
532+
const guard = this.getAuthGuard(db, fieldInfo.type, 'read');
498533

499534
// is|isNot and flat fields conditions are mutually exclusive
500535

501-
if (payload.is || payload.isNot) {
536+
// is and isNot can be null value
537+
538+
if (payload.is !== undefined || payload.isNot !== undefined) {
502539
if (payload.is) {
503-
this.injectGuardForRelationFields(db, fieldInfo.type, payload.is, operation);
540+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.is, guard);
541+
// merge guard with existing "is": { is: { AND: [originalIs, guard] } }
542+
payload.is = this.and(payload.is, mergedGuard);
504543
}
544+
505545
if (payload.isNot) {
506-
this.injectGuardForRelationFields(db, fieldInfo.type, payload.isNot, operation);
546+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload.isNot, guard);
547+
// merge guard with existing "isNot": { isNot: { AND: [originalIsNot, guard] } }
548+
payload.isNot = this.and(payload.isNot, mergedGuard);
507549
}
508-
// merge guard with existing "is": { is: [originalIs, guard] }
509-
payload.is = this.and(payload.is, guard);
510550
} else {
511-
this.injectGuardForRelationFields(db, fieldInfo.type, payload, operation);
551+
const mergedGuard = this.injectReadGuardForRelationFields(db, fieldInfo.type, payload, guard);
512552
// turn direct conditions into: { is: { AND: [ originalConditions, guard ] } }
513-
const combined = this.and(deepcopy(payload), guard);
553+
const combined = this.and(deepcopy(payload), mergedGuard);
514554
Object.keys(payload).forEach((key) => delete payload[key]);
515555
payload.is = combined;
516556
}
@@ -530,7 +570,7 @@ export class PolicyUtil extends QueryUtils {
530570
// inject into relation fields:
531571
// to-many: some/none/every
532572
// to-one: direct-conditions/is/isNot
533-
this.injectGuardForRelationFields(db, model, args.where, 'read');
573+
this.injectReadGuardForRelationFields(db, model, args.where, {});
534574
}
535575

536576
if (injected.where && Object.keys(injected.where).length > 0 && !this.isTrue(injected.where)) {

packages/runtime/src/enhancements/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ type FieldCrudDef = {
214214
};
215215

216216
type FieldReadDef = {
217+
/**
218+
* Field-level Prisma query guard
219+
*/
220+
guard?: PolicyFunc;
221+
217222
/**
218223
* Entity checker
219224
*/

packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,20 +517,25 @@ export class PolicyGenerator {
517517
writer.writeLine('read:');
518518
writer.block(() => {
519519
for (const field of model.fields) {
520-
const policyAttrs = field.attributes.filter((attr) => ['@allow', '@deny'].includes(attr.decl.$refText));
520+
const allows = getPolicyExpressions(field, 'allow', 'read');
521+
const denies = getPolicyExpressions(field, 'deny', 'read');
522+
const overrideAllows = getPolicyExpressions(field, 'allow', 'read', true);
521523

522-
if (policyAttrs.length === 0) {
524+
if (allows.length === 0 && denies.length === 0 && overrideAllows.length === 0) {
523525
continue;
524526
}
525527

526528
writer.write(`${field.name}:`);
527529

528530
writer.block(() => {
531+
// guard
532+
const guardFunc = generateQueryGuardFunction(sourceFile, model, 'read', allows, denies, field);
533+
writer.write(`guard: ${guardFunc.getName()},`);
534+
529535
// checker function
530536
// write all field-level rules as entity checker function
531537
this.writeEntityChecker(field, 'read', writer, sourceFile, false, false);
532538

533-
const overrideAllows = getPolicyExpressions(field, 'allow', 'read', true);
534539
if (overrideAllows.length > 0) {
535540
// override guard function
536541
const denies = getPolicyExpressions(field, 'deny', 'read');
@@ -578,7 +583,6 @@ export class PolicyGenerator {
578583
// because they cannot be checked inside Prisma
579584
this.writeEntityChecker(field, 'update', writer, sourceFile, true, false);
580585

581-
const overrideAllows = getPolicyExpressions(field, 'allow', 'update', true);
582586
if (overrideAllows.length > 0) {
583587
// override guard
584588
const overrideGuardFunc = generateQueryGuardFunction(

0 commit comments

Comments
 (0)