Skip to content

Commit

Permalink
fix(policy): relation filter should respect field-level policies (#1495)
Browse files Browse the repository at this point in the history
  • Loading branch information
ymc9 authored Jun 9, 2024
1 parent ed5133c commit 54e1e02
Show file tree
Hide file tree
Showing 6 changed files with 1,547 additions and 69 deletions.
116 changes: 78 additions & 38 deletions packages/runtime/src/enhancements/policy/policy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -419,98 +439,118 @@ 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<any>(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 &&
typeof payload.every === 'object' &&
// 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<string, any>,
operation: PolicyOperationKind
payload: { is?: any; isNot?: any } & Record<string, any>
) {
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;
}
Expand All @@ -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)) {
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/src/enhancements/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ type FieldCrudDef = {
};

type FieldReadDef = {
/**
* Field-level Prisma query guard
*/
guard?: PolicyFunc;

/**
* Entity checker
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 54e1e02

Please sign in to comment.