Skip to content

fix(policy): revers lookup condition to parent entity is not properly built with compound id fields #2053

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

Merged
merged 7 commits into from
Mar 23, 2025
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
10 changes: 5 additions & 5 deletions packages/runtime/src/enhancements/node/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
} else {
// translate to plain `update` for nested write into base fields
const findArgs = {
where: clone(args.where),
where: clone(args.where ?? {}),
select: this.queryUtils.makeIdSelection(model),
};
await this.injectUpdateHierarchy(db, model, findArgs);
Expand Down Expand Up @@ -959,7 +959,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
this.injectWhereHierarchy(model, (args as any)?.where);
this.doProcessUpdatePayload(model, (args as any)?.data);
} else {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doUpdateMany(tx, model, { ...args, where }, simpleUpdateMany);
});
Expand Down Expand Up @@ -1022,15 +1022,15 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
},

delete: async (model, _args, context) => {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doDelete(tx, model, { where });
});
delete context.parent['delete'];
},

deleteMany: async (model, _args, context) => {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doDeleteMany(tx, model, where);
});
Expand Down Expand Up @@ -1095,7 +1095,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
private async doDeleteMany(db: CrudContract, model: string, where: any): Promise<{ count: number }> {
// query existing entities with id
const idSelection = this.queryUtils.makeIdSelection(model);
const findArgs = { where: clone(where), select: idSelection };
const findArgs = { where: clone(where ?? {}), select: idSelection };
this.injectWhereHierarchy(model, findArgs.where);

if (this.options.logPrismaQuery) {
Expand Down
29 changes: 16 additions & 13 deletions packages/runtime/src/enhancements/node/policy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
const unsafe = isUnsafeMutate(model, args, this.modelMeta);

// handles the connection to upstream entity
const reversedQuery = this.policyUtils.buildReversedQuery(context, true, unsafe);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context, true, unsafe);
if ((!unsafe || context.field.isRelationOwner) && reversedQuery[context.field.backLink]) {
// if mutation is safe, or current field owns the relation (so the other side has no fk),
// and the reverse query contains the back link, then we can build a "connect" with it
Expand Down Expand Up @@ -885,7 +885,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
if (args.skipDuplicates) {
// get a reversed query to include fields inherited from upstream mutation,
// it'll be merged with the create payload for unique constraint checking
const upstreamQuery = this.policyUtils.buildReversedQuery(context);
const upstreamQuery = await this.policyUtils.buildReversedQuery(db, context);
if (await this.hasDuplicatedUniqueConstraint(model, item, upstreamQuery, db)) {
if (this.shouldLogQuery) {
this.logger.info(`[policy] \`createMany\` skipping duplicate ${formatObject(item)}`);
Expand All @@ -910,7 +910,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
if (operation === 'disconnect') {
// disconnect filter is not unique, need to build a reversed query to
// locate the entity and use its id fields as unique filter
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const found = await db[model].findUnique({
where: reversedQuery,
select: this.policyUtils.makeIdSelection(model),
Expand All @@ -936,7 +936,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
const visitor = new NestedWriteVisitor(this.modelMeta, {
update: async (model, args, context) => {
// build a unique query including upstream conditions
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);

// handle not-found
const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter, true);
Expand Down Expand Up @@ -997,7 +997,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
if (preValueSelect) {
select = { ...select, ...preValueSelect };
}
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const currentSetQuery = { select, where: reversedQuery };
this.policyUtils.injectAuthGuardAsWhere(db, currentSetQuery, model, 'read');

Expand Down Expand Up @@ -1027,7 +1027,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
} else {
// we have to process `updateMany` separately because the guard may contain
// filters using relation fields which are not allowed in nested `updateMany`
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const updateWhere = this.policyUtils.and(reversedQuery, updateGuard);
if (this.shouldLogQuery) {
this.logger.info(
Expand Down Expand Up @@ -1066,7 +1066,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr

upsert: async (model, args, context) => {
// build a unique query including upstream conditions
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);

// branch based on if the update target exists
const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter);
Expand All @@ -1090,7 +1090,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr

// convert upsert to update
const convertedUpdate = {
where: args.where,
where: args.where ?? {},
data: this.validateUpdateInputSchema(model, args.update),
};
this.mergeToParent(context.parent, 'update', convertedUpdate);
Expand Down Expand Up @@ -1143,7 +1143,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr

set: async (model, args, context) => {
// find the set of items to be replaced
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const findCurrSetArgs = {
select: this.policyUtils.makeIdSelection(model),
where: reversedQuery,
Expand All @@ -1162,7 +1162,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr

delete: async (model, args, context) => {
// build a unique query including upstream conditions
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);

// handle not-found
await this.policyUtils.checkExistence(db, model, uniqueFilter, true);
Expand All @@ -1179,7 +1179,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
} else {
// we have to process `deleteMany` separately because the guard may contain
// filters using relation fields which are not allowed in nested `deleteMany`
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const deleteWhere = this.policyUtils.and(reversedQuery, guard);
if (this.shouldLogQuery) {
this.logger.info(`[policy] \`deleteMany\` ${model}:\n${formatObject({ where: deleteWhere })}`);
Expand Down Expand Up @@ -1579,12 +1579,15 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
if (this.shouldLogQuery) {
this.logger.info(
`[policy] \`findMany\` ${this.model}: ${formatObject({
where: args.where,
where: args.where ?? {},
select: candidateSelect,
})}`
);
}
const candidates = await tx[this.model].findMany({ where: args.where, select: candidateSelect });
const candidates = await tx[this.model].findMany({
where: args.where ?? {},
select: candidateSelect,
});

// build a ID filter based on id values filtered by the additional checker
const { idFilter } = this.buildIdFilterWithEntityChecker(candidates, entityChecker.func);
Expand Down
3 changes: 0 additions & 3 deletions packages/runtime/src/enhancements/node/policy/policy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
} from '../../../types';
import { getVersion } from '../../../version';
import type { InternalEnhancementOptions } from '../create-enhancement';
import { Logger } from '../logger';
import { QueryUtils } from '../query-utils';
import type {
DelegateConstraint,
Expand All @@ -47,7 +46,6 @@ import { formatObject, prismaClientKnownRequestError } from '../utils';
* Access policy enforcement utilities
*/
export class PolicyUtil extends QueryUtils {
private readonly logger: Logger;
private readonly modelMeta: ModelMeta;
private readonly policy: PolicyDef;
private readonly zodSchemas?: ZodSchemas;
Expand All @@ -62,7 +60,6 @@ export class PolicyUtil extends QueryUtils {
) {
super(db, options);

this.logger = new Logger(db);
this.user = context?.user;

({
Expand Down
39 changes: 34 additions & 5 deletions packages/runtime/src/enhancements/node/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ import {
} from '../../cross';
import type { CrudContract, DbClientContract } from '../../types';
import { getVersion } from '../../version';
import { formatObject } from '../edge';
import { InternalEnhancementOptions } from './create-enhancement';
import { Logger } from './logger';
import { prismaClientUnknownRequestError, prismaClientValidationError } from './utils';

export class QueryUtils {
constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {}
protected readonly logger: Logger;

constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {
this.logger = new Logger(prisma);
}

getIdFields(model: string) {
return getIdFields(this.options.modelMeta, model, true);
Expand Down Expand Up @@ -60,7 +66,12 @@ export class QueryUtils {
/**
* Builds a reversed query for the given nested path.
*/
buildReversedQuery(context: NestedWriteVisitorContext, forMutationPayload = false, unsafeOperation = false) {
async buildReversedQuery(
db: CrudContract,
context: NestedWriteVisitorContext,
forMutationPayload = false,
uncheckedOperation = false
) {
let result, currQuery: any;
let currField: FieldInfo | undefined;

Expand Down Expand Up @@ -102,17 +113,35 @@ export class QueryUtils {
const shouldPreserveRelationCondition =
// doing a mutation
forMutationPayload &&
// and it's a safe mutate
!unsafeOperation &&
// and it's not an unchecked mutate
!uncheckedOperation &&
// and the current segment is the direct parent (the last one is the mutate itself),
// the relation condition should be preserved and will be converted to a "connect" later
i === context.nestingPath.length - 2;

if (fkMapping && !shouldPreserveRelationCondition) {
// turn relation condition into foreign key condition, e.g.:
// { user: { id: 1 } } => { userId: 1 }

let parentPk = visitWhere;
if (Object.keys(fkMapping).some((k) => !(k in parentPk) || parentPk[k] === undefined)) {
// it can happen that the parent condition actually doesn't contain all id fields
// (when the parent condition is not a primary key but unique constraints)
// and in such case we need to load it to get the pks

if (this.options.logPrismaQuery && this.logger.enabled('info')) {
this.logger.info(
`[reverseLookup] \`findUniqueOrThrow\` ${model}: ${formatObject(where)}`
);
}
parentPk = await db[model].findUniqueOrThrow({
where,
select: this.makeIdSelection(model),
});
}

for (const [r, fk] of Object.entries<string>(fkMapping)) {
currQuery[fk] = visitWhere[r];
currQuery[fk] = parentPk[r];
}

if (i > 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ class RequestHandler extends APIHandlerBase {
args.take = limit;
const [entities, count] = await Promise.all([
prisma[type].findMany(args),
prisma[type].count({ where: args.where }),
prisma[type].count({ where: args.where ?? {} }),
]);
const total = count as number;

Expand Down
6 changes: 5 additions & 1 deletion packages/testtools/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ datasource db {

generator js {
provider = 'prisma-client-js'
${options.previewFeatures ? `previewFeatures = ${JSON.stringify(options.previewFeatures)}` : ''}
${
options.previewFeatures
? `previewFeatures = ${JSON.stringify(options.previewFeatures)}`
: 'previewFeatures = ["strictUndefinedChecks"]'
}
}

plugin enhancer {
Expand Down
Loading
Loading