Skip to content

Commit 271d568

Browse files
authored
feat: field-level policy override (#889)
1 parent 4226387 commit 271d568

File tree

9 files changed

+459
-79
lines changed

9 files changed

+459
-79
lines changed

packages/runtime/src/constants.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,20 @@ export const FIELD_LEVEL_READ_CHECKER_PREFIX = 'readFieldCheck$';
8383
*/
8484
export const FIELD_LEVEL_READ_CHECKER_SELECTOR = 'readFieldSelect';
8585

86+
/**
87+
* Prefix for field-level override read guard function name
88+
*/
89+
export const FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX = 'readFieldGuardOverride$';
90+
8691
/**
8792
* Prefix for field-level update guard function name
8893
*/
89-
export const FIELD_LEVEL_UPDATE_GUARD_PREFIX = 'updateFieldCheck$';
94+
export const FIELD_LEVEL_UPDATE_GUARD_PREFIX = 'updateFieldGuard$';
95+
96+
/**
97+
* Prefix for field-level override update guard function name
98+
*/
99+
export const FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX = 'updateFieldGuardOverride$';
90100

91101
/**
92102
* Flag that indicates if the model has field-level access control

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

Lines changed: 161 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ZodError } from 'zod';
77
import { fromZodError } from 'zod-validation-error';
88
import {
99
CrudFailureReason,
10+
FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX,
11+
FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX,
1012
FIELD_LEVEL_READ_CHECKER_PREFIX,
1113
FIELD_LEVEL_READ_CHECKER_SELECTOR,
1214
FIELD_LEVEL_UPDATE_GUARD_PREFIX,
@@ -236,12 +238,7 @@ export class PolicyUtil {
236238
* @returns true if operation is unconditionally allowed, false if unconditionally denied,
237239
* otherwise returns a guard object
238240
*/
239-
getAuthGuard(
240-
db: Record<string, DbOperations>,
241-
model: string,
242-
operation: PolicyOperationKind,
243-
preValue?: any
244-
): object {
241+
getAuthGuard(db: Record<string, DbOperations>, model: string, operation: PolicyOperationKind, preValue?: any) {
245242
const guard = this.policy.guard[lowerCaseFirst(model)];
246243
if (!guard) {
247244
throw this.unknownError(`unable to load policy guard for ${model}`);
@@ -260,23 +257,61 @@ export class PolicyUtil {
260257
}
261258

262259
/**
263-
* Get field-level auth guard
260+
* Get field-level read auth guard that overrides the model-level
264261
*/
265-
getFieldUpdateAuthGuard(db: Record<string, DbOperations>, model: string, field: string): object {
266-
const guard = this.policy.guard[lowerCaseFirst(model)];
267-
if (!guard) {
268-
throw this.unknownError(`unable to load policy guard for ${model}`);
262+
getFieldOverrideReadAuthGuard(db: Record<string, DbOperations>, model: string, field: string) {
263+
const guard = this.requireGuard(model);
264+
265+
const provider = guard[`${FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX}${field}`];
266+
if (provider === undefined) {
267+
// field access is denied by default in override mode
268+
return this.makeFalse();
269269
}
270270

271-
const provider = guard[`${FIELD_LEVEL_UPDATE_GUARD_PREFIX}${field}`];
272271
if (typeof provider === 'boolean') {
273272
return this.reduce(provider);
274273
}
275274

276-
if (!provider) {
275+
const r = provider({ user: this.user }, db);
276+
return this.reduce(r);
277+
}
278+
279+
/**
280+
* Get field-level update auth guard
281+
*/
282+
getFieldUpdateAuthGuard(db: Record<string, DbOperations>, model: string, field: string) {
283+
const guard = this.requireGuard(model);
284+
285+
const provider = guard[`${FIELD_LEVEL_UPDATE_GUARD_PREFIX}${field}`];
286+
if (provider === undefined) {
277287
// field access is allowed by default
278288
return this.makeTrue();
279289
}
290+
291+
if (typeof provider === 'boolean') {
292+
return this.reduce(provider);
293+
}
294+
295+
const r = provider({ user: this.user }, db);
296+
return this.reduce(r);
297+
}
298+
299+
/**
300+
* Get field-level update auth guard that overrides the model-level
301+
*/
302+
getFieldOverrideUpdateAuthGuard(db: Record<string, DbOperations>, model: string, field: string) {
303+
const guard = this.requireGuard(model);
304+
305+
const provider = guard[`${FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX}${field}`];
306+
if (provider === undefined) {
307+
// field access is denied by default in override mode
308+
return this.makeFalse();
309+
}
310+
311+
if (typeof provider === 'boolean') {
312+
return this.reduce(provider);
313+
}
314+
280315
const r = provider({ user: this.user }, db);
281316
return this.reduce(r);
282317
}
@@ -322,10 +357,6 @@ export class PolicyUtil {
322357
*/
323358
injectAuthGuard(db: Record<string, DbOperations>, args: any, model: string, operation: PolicyOperationKind) {
324359
let guard = this.getAuthGuard(db, model, operation);
325-
if (this.isFalse(guard)) {
326-
args.where = this.makeFalse();
327-
return false;
328-
}
329360

330361
if (operation === 'update' && args) {
331362
// merge field-level policy guards
@@ -334,12 +365,32 @@ export class PolicyUtil {
334365
// rejected
335366
args.where = this.makeFalse();
336367
return false;
337-
} else if (fieldUpdateGuard.guard) {
338-
// merge
339-
guard = this.and(guard, fieldUpdateGuard.guard);
368+
} else {
369+
if (fieldUpdateGuard.guard) {
370+
// merge field-level guard
371+
guard = this.and(guard, fieldUpdateGuard.guard);
372+
}
373+
374+
if (fieldUpdateGuard.overrideGuard) {
375+
// merge field-level override guard on the top level
376+
guard = this.or(guard, fieldUpdateGuard.overrideGuard);
377+
}
378+
}
379+
}
380+
381+
if (operation === 'read') {
382+
// merge field-level read override guards
383+
const fieldReadOverrideGuard = this.getFieldReadGuards(db, model, args);
384+
if (fieldReadOverrideGuard) {
385+
guard = this.or(guard, fieldReadOverrideGuard);
340386
}
341387
}
342388

389+
if (this.isFalse(guard)) {
390+
args.where = this.makeFalse();
391+
return false;
392+
}
393+
343394
if (args.where) {
344395
// inject into relation fields:
345396
// to-many: some/none/every
@@ -441,7 +492,8 @@ export class PolicyUtil {
441492
* Injects auth guard for read operations.
442493
*/
443494
injectForRead(db: Record<string, DbOperations>, model: string, args: any) {
444-
const injected: any = {};
495+
// make select and include visible to the injection
496+
const injected: any = { select: args.select, include: args.include };
445497
if (!this.injectAuthGuard(db, injected, model, 'read')) {
446498
return false;
447499
}
@@ -701,9 +753,16 @@ export class PolicyUtil {
701753
}"`,
702754
CrudFailureReason.ACCESS_POLICY_VIOLATION
703755
);
704-
} else if (fieldUpdateGuard.guard) {
705-
// merge
706-
guard = this.and(guard, fieldUpdateGuard.guard);
756+
} else {
757+
if (fieldUpdateGuard.guard) {
758+
// merge field-level guard
759+
guard = this.and(guard, fieldUpdateGuard.guard);
760+
}
761+
762+
if (fieldUpdateGuard.overrideGuard) {
763+
// merge field-level override guard
764+
guard = this.or(guard, fieldUpdateGuard.overrideGuard);
765+
}
707766
}
708767
}
709768

@@ -761,8 +820,33 @@ export class PolicyUtil {
761820
}
762821
}
763822

823+
private getFieldReadGuards(db: Record<string, DbOperations>, model: string, args: { select?: any; include?: any }) {
824+
const allFields = Object.values(getFields(this.modelMeta, model));
825+
826+
// all scalar fields by default
827+
let fields = allFields.filter((f) => !f.isDataModel);
828+
829+
if (args.select) {
830+
// explicitly selected fields
831+
fields = allFields.filter((f) => args.select?.[f.name] === true);
832+
} else if (args.include) {
833+
// included relations
834+
fields.push(...allFields.filter((f) => !fields.includes(f) && args.include[f.name]));
835+
}
836+
837+
if (fields.length === 0) {
838+
// this can happen if only selecting pseudo fields like "_count"
839+
return undefined;
840+
}
841+
842+
const allFieldGuards = fields.map((field) => this.getFieldOverrideReadAuthGuard(db, model, field.name));
843+
return this.and(...allFieldGuards);
844+
}
845+
764846
private getFieldUpdateGuards(db: Record<string, DbOperations>, model: string, args: any) {
765847
const allFieldGuards = [];
848+
const allOverrideFieldGuards = [];
849+
766850
for (const [k, v] of Object.entries<any>(args.data ?? args)) {
767851
if (typeof v === 'undefined') {
768852
continue;
@@ -778,20 +862,41 @@ export class PolicyUtil {
778862
for (const fk of foreignKeys) {
779863
const fieldGuard = this.getFieldUpdateAuthGuard(db, model, fk);
780864
if (this.isFalse(fieldGuard)) {
781-
return { guard: allFieldGuards, rejectedByField: fk };
865+
return { guard: fieldGuard, rejectedByField: fk };
782866
}
867+
868+
// add field guard
783869
allFieldGuards.push(fieldGuard);
870+
871+
// add field override guard
872+
const overrideFieldGuard = this.getFieldOverrideUpdateAuthGuard(db, model, fk);
873+
allOverrideFieldGuards.push(overrideFieldGuard);
784874
}
785875
}
786876
} else {
787877
const fieldGuard = this.getFieldUpdateAuthGuard(db, model, k);
788878
if (this.isFalse(fieldGuard)) {
789-
return { guard: allFieldGuards, rejectedByField: k };
879+
return { guard: fieldGuard, rejectedByField: k };
790880
}
881+
882+
// add field guard
791883
allFieldGuards.push(fieldGuard);
884+
885+
// add field override guard
886+
const overrideFieldGuard = this.getFieldOverrideUpdateAuthGuard(db, model, k);
887+
allOverrideFieldGuards.push(overrideFieldGuard);
792888
}
793889
}
794-
return { guard: this.and(...allFieldGuards), rejectedByField: undefined };
890+
891+
const allFieldsCombined = this.and(...allFieldGuards);
892+
const allOverrideFieldsCombined =
893+
allOverrideFieldGuards.length !== 0 ? this.and(...allOverrideFieldGuards) : undefined;
894+
895+
return {
896+
guard: allFieldsCombined,
897+
overrideGuard: allOverrideFieldsCombined,
898+
rejectedByField: undefined,
899+
};
795900
}
796901

797902
/**
@@ -841,7 +946,13 @@ export class PolicyUtil {
841946
): Promise<{ result: unknown; error?: Error }> {
842947
uniqueFilter = this.clone(uniqueFilter);
843948
this.flattenGeneratedUniqueField(model, uniqueFilter);
844-
const readArgs = { select: selectInclude.select, include: selectInclude.include, where: uniqueFilter };
949+
950+
// make sure only select and include are picked
951+
const selectIncludeClean = this.pick(selectInclude, 'select', 'include');
952+
const readArgs = {
953+
...this.clone(selectIncludeClean),
954+
where: uniqueFilter,
955+
};
845956

846957
const error = this.deniedByPolicy(
847958
model,
@@ -866,7 +977,7 @@ export class PolicyUtil {
866977
return { error, result: undefined };
867978
}
868979

869-
this.postProcessForRead(result, model, selectInclude);
980+
this.postProcessForRead(result, model, selectIncludeClean);
870981
return { result, error: undefined };
871982
}
872983

@@ -1165,6 +1276,19 @@ export class PolicyUtil {
11651276
return value ? deepcopy(value) : {};
11661277
}
11671278

1279+
/**
1280+
* Picks properties from an object.
1281+
*/
1282+
pick<T>(value: T, ...props: (keyof T)[]): Pick<T, (typeof props)[number]> {
1283+
const v: any = value;
1284+
return props.reduce(function (result, prop) {
1285+
if (prop in v) {
1286+
result[prop] = v[prop];
1287+
}
1288+
return result;
1289+
}, {} as any);
1290+
}
1291+
11681292
/**
11691293
* Gets "id" fields for a given model.
11701294
*/
@@ -1218,5 +1342,13 @@ export class PolicyUtil {
12181342
}
12191343
}
12201344

1345+
private requireGuard(model: string) {
1346+
const guard = this.policy.guard[lowerCaseFirst(model)];
1347+
if (!guard) {
1348+
throw this.unknownError(`unable to load policy guard for ${model}`);
1349+
}
1350+
return guard;
1351+
}
1352+
12211353
//#endregion
12221354
}

packages/runtime/src/enhancements/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { z } from 'zod';
33
import {
4+
FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX,
5+
FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX,
46
FIELD_LEVEL_READ_CHECKER_PREFIX,
57
FIELD_LEVEL_READ_CHECKER_SELECTOR,
68
FIELD_LEVEL_UPDATE_GUARD_PREFIX,
@@ -47,7 +49,12 @@ export type PolicyDef = {
4749
Partial<Record<`${PolicyOperationKind}_input`, InputCheckFunc | boolean>> &
4850
// field-level read checker functions or update guard functions
4951
Record<`${typeof FIELD_LEVEL_READ_CHECKER_PREFIX}${string}`, ReadFieldCheckFunc> &
50-
Record<`${typeof FIELD_LEVEL_UPDATE_GUARD_PREFIX}${string}`, PolicyFunc> & {
52+
Record<
53+
| `${typeof FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX}${string}`
54+
| `${typeof FIELD_LEVEL_UPDATE_GUARD_PREFIX}${string}`
55+
| `${typeof FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX}${string}`,
56+
PolicyFunc
57+
> & {
5158
// pre-update value selector
5259
[PRE_UPDATE_VALUE_SELECTOR]?: object;
5360
// field-level read checker selector

0 commit comments

Comments
 (0)