Skip to content
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
2 changes: 2 additions & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"deepcopy": "^2.1.0",
"lower-case-first": "^2.0.2",
"pluralize": "^8.0.0",
"semver": "^7.3.8",
"superjson": "^1.11.0",
"tslib": "^2.4.1",
"upper-case-first": "^2.0.2",
Expand All @@ -71,6 +72,7 @@
"@types/jest": "^29.5.0",
"@types/node": "^18.0.0",
"@types/pluralize": "^0.0.29",
"@types/semver": "^7.3.13",
"copyfiles": "^2.4.1",
"rimraf": "^3.0.2",
"typescript": "^4.9.3"
Expand Down
135 changes: 79 additions & 56 deletions packages/runtime/src/enhancements/policy/policy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
PolicyOperationKind,
PrismaWriteActionType,
} from '../../types';
import { getVersion } from '../../version';
import { getPrismaVersion, getVersion } from '../../version';
import { getFields, resolveField } from '../model-meta';
import { NestedWriteVisitor, type NestedWriteVisitorContext } from '../nested-write-vistor';
import type { ModelMeta, PolicyDef, PolicyFunc, ZodSchemas } from '../types';
Expand All @@ -36,6 +36,7 @@ import {
prismaClientUnknownRequestError,
} from '../utils';
import { Logger } from './logger';
import semver from 'semver';

/**
* Access policy enforcement utilities
Expand All @@ -45,6 +46,8 @@ export class PolicyUtil {
// @ts-ignore
private readonly logger: Logger;

private supportNestedToOneFilter = false;

constructor(
private readonly db: DbClientContract,
private readonly modelMeta: ModelMeta,
Expand All @@ -54,6 +57,10 @@ export class PolicyUtil {
private readonly logPrismaQuery?: boolean
) {
this.logger = new Logger(db);

// use Prisma version to detect if we can filter when nested-fetching to-one relation
const prismaVersion = getPrismaVersion();
this.supportNestedToOneFilter = prismaVersion ? semver.gte(prismaVersion, '4.8.0') : false;
}

/**
Expand Down Expand Up @@ -334,20 +341,29 @@ export class PolicyUtil {
}

const idFields = this.getIdFields(model);

for (const field of getModelFields(injectTarget)) {
const fieldInfo = resolveField(this.modelMeta, model, field);
if (!fieldInfo || !fieldInfo.isDataModel) {
// only care about relation fields
continue;
}

if (fieldInfo.isArray) {
if (
fieldInfo.isArray ||
// if Prisma version is high enough to support filtering directly when
// fetching a nullable to-one relation, let's do it that way
// https://github.com/prisma/prisma/discussions/20350
(this.supportNestedToOneFilter && fieldInfo.isOptional)
) {
if (typeof injectTarget[field] !== 'object') {
injectTarget[field] = {};
}
// inject extra condition for to-many relation

// inject extra condition for to-many or nullable to-one relation
await this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read');

// recurse
await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]);
} else {
// there's no way of injecting condition for to-one relation, so if there's
// "select" clause we make sure 'id' fields are selected and check them against
Expand All @@ -361,9 +377,6 @@ export class PolicyUtil {
}
}
}

// recurse
await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]);
}
}

Expand All @@ -373,69 +386,79 @@ export class PolicyUtil {
* omitted.
*/
async postProcessForRead(data: any, model: string, args: any, operation: PolicyOperationKind) {
for (const entityData of enumerate(data)) {
if (typeof entityData !== 'object' || !entityData) {
continue;
}
await Promise.all(
enumerate(data).map((entityData) => this.postProcessSingleEntityForRead(entityData, model, args, operation))
);
}

// strip auxiliary fields
for (const auxField of AUXILIARY_FIELDS) {
if (auxField in entityData) {
delete entityData[auxField];
}
private async postProcessSingleEntityForRead(data: any, model: string, args: any, operation: PolicyOperationKind) {
if (typeof data !== 'object' || !data) {
return;
}

// strip auxiliary fields
for (const auxField of AUXILIARY_FIELDS) {
if (auxField in data) {
delete data[auxField];
}
}

const injectTarget = args.select ?? args.include;
if (!injectTarget) {
return;
}

const injectTarget = args.select ?? args.include;
if (!injectTarget) {
// recurse into nested entities
for (const field of Object.keys(injectTarget)) {
const fieldData = data[field];
if (typeof fieldData !== 'object' || !fieldData) {
continue;
}

// recurse into nested entities
for (const field of Object.keys(injectTarget)) {
const fieldData = entityData[field];
if (typeof fieldData !== 'object' || !fieldData) {
continue;
}
const fieldInfo = resolveField(this.modelMeta, model, field);
if (fieldInfo) {
if (
fieldInfo.isDataModel &&
!fieldInfo.isArray &&
// if Prisma version supports filtering nullable to-one relation, no need to further check
!(this.supportNestedToOneFilter && fieldInfo.isOptional)
) {
// to-one relation data cannot be trimmed by injected guards, we have to
// post-check them
const ids = this.getEntityIds(fieldInfo.type, fieldData);

if (Object.keys(ids).length !== 0) {
if (this.logger.enabled('info')) {
this.logger.info(
`Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
);
}

const fieldInfo = resolveField(this.modelMeta, model, field);
if (fieldInfo) {
if (fieldInfo.isDataModel && !fieldInfo.isArray) {
// to-one relation data cannot be trimmed by injected guards, we have to
// post-check them
const ids = this.getEntityIds(fieldInfo.type, fieldData);

if (Object.keys(ids).length !== 0) {
// if (this.logger.enabled('info')) {
// this.logger.info(
// `Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
// );
// }
try {
await this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db);
} catch (err) {
if (
isPrismaClientKnownRequestError(err) &&
err.code === PrismaErrorCode.CONSTRAINED_FAILED
) {
// denied by policy
if (fieldInfo.isOptional) {
// if the relation is optional, just nullify it
entityData[field] = null;
} else {
// otherwise reject
throw err;
}
try {
await this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db);
} catch (err) {
if (
isPrismaClientKnownRequestError(err) &&
err.code === PrismaErrorCode.CONSTRAINED_FAILED
) {
// denied by policy
if (fieldInfo.isOptional) {
// if the relation is optional, just nullify it
data[field] = null;
} else {
// unknown error
// otherwise reject
throw err;
}
} else {
// unknown error
throw err;
}
}
}

// recurse
await this.postProcessForRead(fieldData, fieldInfo.type, injectTarget[field], operation);
}

// recurse
await this.postProcessForRead(fieldData, fieldInfo.type, injectTarget[field], operation);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './enhancements';
export * from './error';
export * from './types';
export * from './validation';
export * from './version';
29 changes: 29 additions & 0 deletions packages/runtime/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'path';

/* eslint-disable @typescript-eslint/no-var-requires */
export function getVersion() {
try {
Expand All @@ -11,3 +13,30 @@ export function getVersion() {
}
}
}

/**
* Gets installed Prisma version by first checking "@prisma/client" and if not available,
* "prisma".
*/
export function getPrismaVersion(): string | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('@prisma/client/package.json').version;
} catch {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('prisma/package.json').version;
} catch {
if (process.env.ZENSTACK_TEST === '1') {
// test environment
try {
return require(path.resolve('./node_modules/@prisma/client/package.json')).version;
} catch {
return undefined;
}
}

return undefined;
}
}
}
18 changes: 14 additions & 4 deletions packages/schema/src/plugins/prisma/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,7 @@ export default class PrismaSchemaGenerator {
const provider = generator.fields.find((f) => f.name === 'provider');
if (provider?.value === 'prisma-client-js') {
const prismaVersion = getPrismaVersion();
if (prismaVersion && semver.lt(prismaVersion, '4.7.0')) {
// insert interactiveTransactions preview feature
if (prismaVersion) {
let previewFeatures = generator.fields.find((f) => f.name === 'previewFeatures');
if (!previewFeatures) {
previewFeatures = { name: 'previewFeatures', value: [] };
Expand All @@ -261,8 +260,19 @@ export default class PrismaSchemaGenerator {
if (!Array.isArray(previewFeatures.value)) {
throw new PluginError(name, 'option "previewFeatures" must be an array');
}
if (!previewFeatures.value.includes('interactiveTransactions')) {
previewFeatures.value.push('interactiveTransactions');

if (semver.lt(prismaVersion, '4.7.0')) {
// interactiveTransactions feature is opt-in before 4.7.0
if (!previewFeatures.value.includes('interactiveTransactions')) {
previewFeatures.value.push('interactiveTransactions');
}
}

if (semver.gte(prismaVersion, '4.8.0') && semver.lt(prismaVersion, '5.0.0')) {
// extendedWhereUnique feature is opt-in during [4.8.0, 5.0.0)
if (!previewFeatures.value.includes('extendedWhereUnique')) {
previewFeatures.value.push('extendedWhereUnique');
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createId } from '@paralleldrive/cuid2';
import { getPrismaVersion } from '@zenstackhq/sdk';
import exitHook from 'async-exit-hook';
import { CommanderError } from 'commander';
import { init, Mixpanel } from 'mixpanel';
Expand All @@ -8,7 +9,6 @@ import sleep from 'sleep-promise';
import { CliError } from './cli/cli-error';
import { TELEMETRY_TRACKING_TOKEN } from './constants';
import { getVersion } from './utils/version-utils';
import { getPrismaVersion } from '@zenstackhq/sdk';

/**
* Telemetry events
Expand Down
22 changes: 4 additions & 18 deletions packages/sdk/src/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { DMMF } from '@prisma/generator-helper';
import { getDMMF as getDMMF4 } from '@prisma/internals';
import { getDMMF as getDMMF5 } from '@prisma/internals-v5';
import { getPrismaVersion } from '@zenstackhq/runtime';
import path from 'path';
import * as semver from 'semver';
import { GeneratorDecl, Model, Plugin, isGeneratorDecl, isPlugin } from './ast';
import { getLiteral } from './utils';

// reexport
export { getPrismaVersion } from '@zenstackhq/runtime';

/**
* Given a ZModel and an import context directory, compute the import spec for the Prisma Client.
*/
Expand Down Expand Up @@ -65,24 +69,6 @@ function normalizePath(p: string) {
return p ? p.split(path.sep).join(path.posix.sep) : p;
}

/**
* Gets installed Prisma version by first checking "@prisma/client" and if not available,
* "prisma".
*/
export function getPrismaVersion(): string | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('@prisma/client/package.json').version;
} catch {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('prisma/package.json').version;
} catch {
return undefined;
}
}
}

export type GetDMMFOptions = {
datamodel?: string;
cwd?: string;
Expand Down
4 changes: 2 additions & 2 deletions packages/testtools/src/package.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^4.0.0",
"@prisma/client": "^4.8.0",
"@zenstackhq/runtime": "file:<root>/packages/runtime/dist",
"@zenstackhq/swr": "file:<root>/packages/plugins/swr/dist",
"@zenstackhq/trpc": "file:<root>/packages/plugins/trpc/dist",
"@zenstackhq/openapi": "file:<root>/packages/plugins/openapi/dist",
"prisma": "^4.0.0",
"prisma": "^4.8.0",
"typescript": "^4.9.3",
"zenstack": "file:<root>/packages/schema/dist",
"zod": "3.21.1"
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading