Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(policy): relation filter should respect field-level policies #1495

Merged
merged 3 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading