Skip to content

Commit 738bba6

Browse files
authored
fix(delegate): enforcing concrete model policies when read from a delegate base (#1726)
1 parent cb68815 commit 738bba6

File tree

6 files changed

+312
-24
lines changed

6 files changed

+312
-24
lines changed

packages/runtime/src/enhancements/node/create-enhancement.ts

+31-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import semver from 'semver';
22
import { PRISMA_MINIMUM_VERSION } from '../../constants';
33
import { isDelegateModel, type ModelMeta } from '../../cross';
4-
import type { EnhancementContext, EnhancementKind, EnhancementOptions, ZodSchemas } from '../../types';
4+
import type {
5+
DbClientContract,
6+
EnhancementContext,
7+
EnhancementKind,
8+
EnhancementOptions,
9+
ZodSchemas,
10+
} from '../../types';
511
import { withDefaultAuth } from './default-auth';
612
import { withDelegate } from './delegate';
713
import { Logger } from './logger';
814
import { withOmit } from './omit';
915
import { withPassword } from './password';
10-
import { withPolicy } from './policy';
16+
import { policyProcessIncludeRelationPayload, withPolicy } from './policy';
1117
import type { PolicyDef } from './types';
1218

1319
/**
@@ -41,6 +47,18 @@ export type InternalEnhancementOptions = EnhancementOptions & {
4147
*/
4248
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4349
prismaModule: any;
50+
51+
/**
52+
* A callback shared among enhancements to process the payload for including a relation
53+
* field. e.g.: `{ author: true }`.
54+
*/
55+
processIncludeRelationPayload?: (
56+
prisma: DbClientContract,
57+
model: string,
58+
payload: unknown,
59+
options: InternalEnhancementOptions,
60+
context: EnhancementContext | undefined
61+
) => Promise<void>;
4462
};
4563

4664
/**
@@ -89,7 +107,7 @@ export function createEnhancement<DbClient extends object>(
89107
'Your ZModel contains delegate models but "delegate" enhancement kind is not enabled. This may result in unexpected behavior.'
90108
);
91109
} else {
92-
result = withDelegate(result, options);
110+
result = withDelegate(result, options, context);
93111
}
94112
}
95113

@@ -103,6 +121,16 @@ export function createEnhancement<DbClient extends object>(
103121
// 'policy' and 'validation' enhancements are both enabled by `withPolicy`
104122
if (kinds.includes('policy') || kinds.includes('validation')) {
105123
result = withPolicy(result, options, context);
124+
125+
// if any enhancement is to introduce an inclusion of a relation field, the
126+
// inclusion payload must be processed by the policy enhancement for injecting
127+
// access control rules
128+
129+
// TODO: this is currently a global callback shared among all enhancements, which
130+
// is far from ideal
131+
132+
options.processIncludeRelationPayload = policyProcessIncludeRelationPayload;
133+
106134
if (kinds.includes('policy') && hasDefaultAuth) {
107135
// @default(auth()) proxy
108136
result = withDefaultAuth(result, options, context);

packages/runtime/src/enhancements/node/delegate.ts

+43-19
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,22 @@ import {
1515
isDelegateModel,
1616
resolveField,
1717
} from '../../cross';
18-
import type { CrudContract, DbClientContract } from '../../types';
18+
import type { CrudContract, DbClientContract, EnhancementContext } from '../../types';
1919
import type { InternalEnhancementOptions } from './create-enhancement';
2020
import { Logger } from './logger';
2121
import { DefaultPrismaProxyHandler, makeProxy } from './proxy';
2222
import { QueryUtils } from './query-utils';
2323
import { formatObject, prismaClientValidationError } from './utils';
2424

25-
export function withDelegate<DbClient extends object>(prisma: DbClient, options: InternalEnhancementOptions): DbClient {
25+
export function withDelegate<DbClient extends object>(
26+
prisma: DbClient,
27+
options: InternalEnhancementOptions,
28+
context: EnhancementContext | undefined
29+
): DbClient {
2630
return makeProxy(
2731
prisma,
2832
options.modelMeta,
29-
(_prisma, model) => new DelegateProxyHandler(_prisma as DbClientContract, model, options),
33+
(_prisma, model) => new DelegateProxyHandler(_prisma as DbClientContract, model, options, context),
3034
'delegate'
3135
);
3236
}
@@ -35,7 +39,12 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
3539
private readonly logger: Logger;
3640
private readonly queryUtils: QueryUtils;
3741

38-
constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
42+
constructor(
43+
prisma: DbClientContract,
44+
model: string,
45+
options: InternalEnhancementOptions,
46+
private readonly context: EnhancementContext | undefined
47+
) {
3948
super(prisma, model, options);
4049
this.logger = new Logger(prisma);
4150
this.queryUtils = new QueryUtils(prisma, this.options);
@@ -76,7 +85,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
7685
args = args ? clone(args) : {};
7786

7887
this.injectWhereHierarchy(model, args?.where);
79-
this.injectSelectIncludeHierarchy(model, args);
88+
await this.injectSelectIncludeHierarchy(model, args);
8089

8190
// discriminator field is needed during post process to determine the
8291
// actual concrete model type
@@ -166,7 +175,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
166175
});
167176
}
168177

169-
private injectSelectIncludeHierarchy(model: string, args: any) {
178+
private async injectSelectIncludeHierarchy(model: string, args: any) {
170179
if (!args || typeof args !== 'object') {
171180
return;
172181
}
@@ -186,7 +195,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
186195
// make sure the payload is an object
187196
args[kind][field] = {};
188197
}
189-
this.injectSelectIncludeHierarchy(fieldInfo.type, args[kind][field]);
198+
await this.injectSelectIncludeHierarchy(fieldInfo.type, args[kind][field]);
190199
}
191200
}
192201

@@ -208,7 +217,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
208217
// make sure the payload is an object
209218
args[kind][field] = nextValue = {};
210219
}
211-
this.injectSelectIncludeHierarchy(fieldInfo.type, nextValue);
220+
await this.injectSelectIncludeHierarchy(fieldInfo.type, nextValue);
212221
}
213222
}
214223
}
@@ -220,11 +229,11 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
220229
this.injectBaseIncludeRecursively(model, args);
221230

222231
// include sub models downwards
223-
this.injectConcreteIncludeRecursively(model, args);
232+
await this.injectConcreteIncludeRecursively(model, args);
224233
}
225234
}
226235

227-
private buildSelectIncludeHierarchy(model: string, args: any) {
236+
private async buildSelectIncludeHierarchy(model: string, args: any) {
228237
args = clone(args);
229238
const selectInclude: any = this.extractSelectInclude(args) || {};
230239

@@ -248,7 +257,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
248257

249258
if (!selectInclude.select) {
250259
this.injectBaseIncludeRecursively(model, selectInclude);
251-
this.injectConcreteIncludeRecursively(model, selectInclude);
260+
await this.injectConcreteIncludeRecursively(model, selectInclude);
252261
}
253262
return selectInclude;
254263
}
@@ -319,7 +328,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
319328
this.injectBaseIncludeRecursively(base.name, selectInclude.include[baseRelationName]);
320329
}
321330

322-
private injectConcreteIncludeRecursively(model: string, selectInclude: any) {
331+
private async injectConcreteIncludeRecursively(model: string, selectInclude: any) {
323332
const modelInfo = getModelInfo(this.options.modelMeta, model);
324333
if (!modelInfo) {
325334
return;
@@ -333,13 +342,27 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
333342
for (const subModel of subModels) {
334343
// include sub model relation field
335344
const subRelationName = this.makeAuxRelationName(subModel);
345+
const includePayload: any = {};
346+
347+
if (this.options.processIncludeRelationPayload) {
348+
// use the callback in options to process the include payload, so enhancements
349+
// like 'policy' can do extra work (e.g., inject policy rules)
350+
await this.options.processIncludeRelationPayload(
351+
this.prisma,
352+
subModel.name,
353+
includePayload,
354+
this.options,
355+
this.context
356+
);
357+
}
358+
336359
if (selectInclude.select) {
337-
selectInclude.include = { [subRelationName]: {}, ...selectInclude.select };
360+
selectInclude.include = { [subRelationName]: includePayload, ...selectInclude.select };
338361
delete selectInclude.select;
339362
} else {
340-
selectInclude.include = { [subRelationName]: {}, ...selectInclude.include };
363+
selectInclude.include = { [subRelationName]: includePayload, ...selectInclude.include };
341364
}
342-
this.injectConcreteIncludeRecursively(subModel.name, selectInclude.include[subRelationName]);
365+
await this.injectConcreteIncludeRecursively(subModel.name, selectInclude.include[subRelationName]);
343366
}
344367
}
345368

@@ -480,7 +503,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
480503
args = clone(args);
481504

482505
await this.injectCreateHierarchy(model, args);
483-
this.injectSelectIncludeHierarchy(model, args);
506+
await this.injectSelectIncludeHierarchy(model, args);
484507

485508
if (this.options.logPrismaQuery) {
486509
this.logger.info(`[delegate] \`create\` ${this.getModelName(model)}: ${formatObject(args)}`);
@@ -702,7 +725,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
702725

703726
args = clone(args);
704727
this.injectWhereHierarchy(this.model, (args as any)?.where);
705-
this.injectSelectIncludeHierarchy(this.model, args);
728+
await this.injectSelectIncludeHierarchy(this.model, args);
706729
if (args.create) {
707730
this.doProcessCreatePayload(this.model, args.create);
708731
}
@@ -721,7 +744,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
721744
args = clone(args);
722745

723746
await this.injectUpdateHierarchy(db, model, args);
724-
this.injectSelectIncludeHierarchy(model, args);
747+
await this.injectSelectIncludeHierarchy(model, args);
725748

726749
if (this.options.logPrismaQuery) {
727750
this.logger.info(`[delegate] \`update\` ${this.getModelName(model)}: ${formatObject(args)}`);
@@ -915,7 +938,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
915938
}
916939

917940
return this.queryUtils.transaction(this.prisma, async (tx) => {
918-
const selectInclude = this.buildSelectIncludeHierarchy(this.model, args);
941+
const selectInclude = await this.buildSelectIncludeHierarchy(this.model, args);
919942

920943
// make sure id fields are selected
921944
const idFields = this.getIdFields(this.model);
@@ -967,6 +990,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
967990

968991
private async doDelete(db: CrudContract, model: string, args: any): Promise<unknown> {
969992
this.injectWhereHierarchy(model, args.where);
993+
await this.injectSelectIncludeHierarchy(model, args);
970994

971995
if (this.options.logPrismaQuery) {
972996
this.logger.info(`[delegate] \`delete\` ${this.getModelName(model)}: ${formatObject(args)}`);

packages/runtime/src/enhancements/node/policy/index.ts

+18
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { InternalEnhancementOptions } from '../create-enhancement';
77
import { Logger } from '../logger';
88
import { makeProxy } from '../proxy';
99
import { PolicyProxyHandler } from './handler';
10+
import { PolicyUtil } from './policy-utils';
1011

1112
/**
1213
* Gets an enhanced Prisma client with access policy check.
@@ -60,3 +61,20 @@ export function withPolicy<DbClient extends object>(
6061
options?.errorTransformer
6162
);
6263
}
64+
65+
/**
66+
* Function for processing a payload for including a relation field in a query.
67+
* @param model The relation's model name
68+
* @param payload The payload to process
69+
*/
70+
export async function policyProcessIncludeRelationPayload(
71+
prisma: DbClientContract,
72+
model: string,
73+
payload: unknown,
74+
options: InternalEnhancementOptions,
75+
context: EnhancementContext | undefined
76+
) {
77+
const utils = new PolicyUtil(prisma, options, context);
78+
await utils.injectForRead(prisma, model, payload);
79+
await utils.injectReadCheckSelect(model, payload);
80+
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,9 @@ export class PolicyUtil extends QueryUtils {
10981098
}
10991099
const result = await db[model].findFirst(readArgs);
11001100
if (!result) {
1101+
if (this.shouldLogQuery) {
1102+
this.logger.info(`[policy] cannot read back ${model}`);
1103+
}
11011104
return { error, result: undefined };
11021105
}
11031106

packages/runtime/src/enhancements/node/proxy.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class DefaultPrismaProxyHandler implements PrismaProxyHandler {
6969
protected readonly options: InternalEnhancementOptions
7070
) {}
7171

72-
protected withFluentCall(method: keyof PrismaProxyHandler, args: any, postProcess = true): Promise<unknown> {
72+
protected withFluentCall(method: PrismaProxyActions, args: any, postProcess = true): Promise<unknown> {
7373
args = args ? clone(args) : {};
7474
const promise = createFluentPromise(
7575
async () => {
@@ -84,7 +84,7 @@ export class DefaultPrismaProxyHandler implements PrismaProxyHandler {
8484
return promise;
8585
}
8686

87-
protected deferred<TResult = unknown>(method: keyof PrismaProxyHandler, args: any, postProcess = true) {
87+
protected deferred<TResult = unknown>(method: PrismaProxyActions, args: any, postProcess = true) {
8888
return createDeferredPromise<TResult>(async () => {
8989
args = await this.preprocessArgs(method, args);
9090
const r = await this.prisma[this.model][method](args);

0 commit comments

Comments
 (0)