Skip to content

refactor: unify enhancement API to enhance #940

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 4 commits into from
Jan 10, 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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The `zenstack` CLI and ZModel VSCode extension implementation. The package also

### `runtime`

Runtime enhancements to PrismaClient, including infrastructure for creating transparent proxies and concrete implementations for the `withPolicy`, `withPassword`, and `withOmit` proxies.
Runtime enhancements to PrismaClient, including infrastructure for creating transparent proxies and concrete implementations of various proxies.

### `server`

Expand Down
4 changes: 2 additions & 2 deletions packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ At runtime, transparent proxies are created around Prisma clients for intercepti
// Next.js example: pages/api/model/[...path].ts

import { requestHandler } from '@zenstackhq/next';
import { withPolicy } from '@zenstackhq/runtime';
import { enhance } from '@zenstackhq/runtime';
import { getSessionUser } from '@lib/auth';
import { prisma } from '@lib/db';

export default requestHandler({
getPrisma: (req, res) => withPolicy(prisma, { user: getSessionUser(req, res) }),
getPrisma: (req, res) => enhance(prisma, { user: getSessionUser(req, res) }),
});
```

Expand Down
5 changes: 0 additions & 5 deletions packages/runtime/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ export enum PrismaErrorCode {
DEPEND_ON_RECORD_NOT_FOUND = 'P2025',
}

/**
* Field name for storing in-transaction flag
*/
export const PRISMA_TX_FLAG = '$__zenstack_tx';

/**
* Field name for getting current enhancer
*/
Expand Down
123 changes: 110 additions & 13 deletions packages/runtime/src/enhancements/enhance.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,123 @@
import { withOmit, WithOmitOptions } from './omit';
import { withPassword, WithPasswordOptions } from './password';
import { withPolicy, WithPolicyContext, WithPolicyOptions } from './policy';
import semver from 'semver';
import { PRISMA_MINIMUM_VERSION } from '../constants';
import { ModelMeta } from '../cross';
import type { AuthUser } from '../types';
import { withOmit } from './omit';
import { withPassword } from './password';
import { withPolicy } from './policy';
import type { ErrorTransformer } from './proxy';
import type { PolicyDef, ZodSchemas } from './types';

/**
* Options @see enhance
* Kinds of enhancements to `PrismaClient`
*/
export type EnhancementOptions = WithPolicyOptions & WithPasswordOptions & WithOmitOptions;
export enum EnhancementKind {
Password = 'password',
Omit = 'omit',
Policy = 'policy',
}

/**
* Transaction isolation levels: https://www.prisma.io/docs/orm/prisma-client/queries/transactions#transaction-isolation-level
*/
export type TransactionIsolationLevel =
| 'ReadUncommitted'
| 'ReadCommitted'
| 'RepeatableRead'
| 'Snapshot'
| 'Serializable';

/**
* Options for {@link createEnhancement}
*/
export type EnhancementOptions = {
/**
* Policy definition
*/
policy: PolicyDef;

/**
* Model metadata
*/
modelMeta: ModelMeta;

/**
* Zod schemas for validation
*/
zodSchemas?: ZodSchemas;

/**
* Whether to log Prisma query
*/
logPrismaQuery?: boolean;

/**
* The Node module that contains PrismaClient
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismaModule: any;

/**
* The kinds of enhancements to apply. By default all enhancements are applied.
*/
kinds?: EnhancementKind[];

/**
* Hook for transforming errors before they are thrown to the caller.
*/
errorTransformer?: ErrorTransformer;

/**
* The `maxWait` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack.
*/
transactionMaxWait?: number;

/**
* The `timeout` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack.
*/
transactionTimeout?: number;

/**
* The `isolationLevel` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack.
*/
transactionIsolationLevel?: TransactionIsolationLevel;
};

/**
* Context for creating enhanced `PrismaClient`
*/
export type EnhancementContext = {
user?: AuthUser;
};

let hasPassword: boolean | undefined = undefined;
let hasOmit: boolean | undefined = undefined;

/**
* Gets a Prisma client enhanced with all essential behaviors, including access
* Gets a Prisma client enhanced with all enhancement behaviors, including access
* policy, field validation, field omission and password hashing.
*
* It's a shortcut for calling withOmit(withPassword(withPolicy(prisma, options))).
*
* @param prisma The Prisma client to enhance.
* @param context The context to for evaluating access policies.
* @param context Context.
* @param options Options.
*/
export function createEnhancement<DbClient extends object>(
prisma: DbClient,
options: EnhancementOptions,
context?: WithPolicyContext
context?: EnhancementContext
) {
if (!prisma) {
throw new Error('Invalid prisma instance');
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaVer = (prisma as any)._clientVersion;
if (prismaVer && semver.lt(prismaVer, PRISMA_MINIMUM_VERSION)) {
console.warn(
`ZenStack requires Prisma version "${PRISMA_MINIMUM_VERSION}" or higher. Detected version is "${prismaVer}".`
);
}

let result = prisma;

if (hasPassword === undefined || hasOmit === undefined) {
Expand All @@ -33,18 +126,22 @@ export function createEnhancement<DbClient extends object>(
hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit'));
}

if (hasPassword) {
const kinds = options.kinds ?? [EnhancementKind.Password, EnhancementKind.Omit, EnhancementKind.Policy];

if (hasPassword && kinds.includes(EnhancementKind.Password)) {
// @password proxy
result = withPassword(result, options);
}

if (hasOmit) {
if (hasOmit && kinds.includes(EnhancementKind.Omit)) {
// @omit proxy
result = withOmit(result, options);
}

// policy proxy
result = withPolicy(result, options, context);
if (kinds.includes(EnhancementKind.Policy)) {
result = withPolicy(result, options, context);
}

return result;
}
4 changes: 0 additions & 4 deletions packages/runtime/src/enhancements/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
export * from '../cross';
export * from './enhance';
export * from './omit';
export * from './password';
export * from './policy';
export * from './types';
export * from './utils';
export * from './where-visitor';
18 changes: 5 additions & 13 deletions packages/runtime/src/enhancements/omit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,15 @@

import { enumerate, getModelFields, resolveField, type ModelMeta } from '../cross';
import { DbClientContract } from '../types';
import { EnhancementOptions } from './enhance';
import { DefaultPrismaProxyHandler, makeProxy } from './proxy';
import { CommonEnhancementOptions } from './types';

/**
* Options for @see withOmit
* Gets an enhanced Prisma client that supports `@omit` attribute.
*
* @private
*/
export interface WithOmitOptions extends CommonEnhancementOptions {
/**
* Model metadata
*/
modelMeta: ModelMeta;
}

/**
* Gets an enhanced Prisma client that supports @omit attribute.
*/
export function withOmit<DbClient extends object>(prisma: DbClient, options: WithOmitOptions): DbClient {
export function withOmit<DbClient extends object>(prisma: DbClient, options: EnhancementOptions): DbClient {
return makeProxy(
prisma,
options.modelMeta,
Expand Down
18 changes: 5 additions & 13 deletions packages/runtime/src/enhancements/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,15 @@ import { hash } from 'bcryptjs';
import { DEFAULT_PASSWORD_SALT_LENGTH } from '../constants';
import { NestedWriteVisitor, type ModelMeta, type PrismaWriteActionType } from '../cross';
import { DbClientContract } from '../types';
import { EnhancementOptions } from './enhance';
import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy';
import { CommonEnhancementOptions } from './types';

/**
* Options for @see withPassword
* Gets an enhanced Prisma client that supports `@password` attribute.
*
* @private
*/
export interface WithPasswordOptions extends CommonEnhancementOptions {
/**
* Model metadata
*/
modelMeta: ModelMeta;
}

/**
* Gets an enhanced Prisma client that supports @password attribute.
*/
export function withPassword<DbClient extends object = any>(prisma: DbClient, options: WithPasswordOptions): DbClient {
export function withPassword<DbClient extends object = any>(prisma: DbClient, options: EnhancementOptions): DbClient {
return makeProxy(
prisma,
options.modelMeta,
Expand Down
51 changes: 20 additions & 31 deletions packages/runtime/src/enhancements/policy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { lowerCaseFirst } from 'lower-case-first';
import invariant from 'tiny-invariant';
import { upperCaseFirst } from 'upper-case-first';
import { fromZodError } from 'zod-validation-error';
import { CrudFailureReason, PRISMA_TX_FLAG } from '../../constants';
import { CrudFailureReason } from '../../constants';
import {
ModelDataVisitor,
NestedWriteVisitor,
Expand All @@ -16,9 +16,9 @@ import {
type FieldInfo,
type ModelMeta,
} from '../../cross';
import { AuthUser, DbClientContract, DbOperations, PolicyOperationKind } from '../../types';
import { DbClientContract, DbOperations, PolicyOperationKind } from '../../types';
import type { EnhancementContext, EnhancementOptions } from '../enhance';
import { PrismaProxyHandler } from '../proxy';
import type { PolicyDef, ZodSchemas } from '../types';
import { formatObject, prismaClientValidationError } from '../utils';
import { Logger } from './logger';
import { PolicyUtil } from './policy-utils';
Expand All @@ -41,28 +41,22 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
private readonly logger: Logger;
private readonly utils: PolicyUtil;
private readonly model: string;
private readonly modelMeta: ModelMeta;
private readonly prismaModule: any;
private readonly logPrismaQuery?: boolean;

constructor(
private readonly prisma: DbClient,
private readonly policy: PolicyDef,
private readonly modelMeta: ModelMeta,
private readonly zodSchemas: ZodSchemas | undefined,
private readonly prismaModule: any,
model: string,
private readonly user?: AuthUser,
private readonly logPrismaQuery?: boolean
private readonly options: EnhancementOptions,
private readonly context?: EnhancementContext
) {
this.logger = new Logger(prisma);
this.utils = new PolicyUtil(
this.prisma,
this.modelMeta,
this.policy,
this.zodSchemas,
this.prismaModule,
this.user,
this.shouldLogQuery
);
this.model = lowerCaseFirst(model);

({ modelMeta: this.modelMeta, logPrismaQuery: this.logPrismaQuery, prismaModule: this.prismaModule } = options);

this.utils = new PolicyUtil(prisma, options, context, this.shouldLogQuery);
}

private get modelClient() {
Expand Down Expand Up @@ -1278,11 +1272,15 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
}

private transaction(action: (tx: Record<string, DbOperations>) => Promise<any>) {
if (this.prisma[PRISMA_TX_FLAG]) {
if (this.prisma['$transaction']) {
return this.prisma.$transaction((tx) => action(tx), {
maxWait: this.options.transactionMaxWait,
timeout: this.options.transactionTimeout,
isolationLevel: this.options.transactionIsolationLevel,
});
} else {
// already in transaction, don't nest
return action(this.prisma);
} else {
return this.prisma.$transaction((tx) => action(tx), { maxWait: 100000, timeout: 100000 });
}
}

Expand All @@ -1295,16 +1293,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
}

private makeHandler(model: string) {
return new PolicyProxyHandler(
this.prisma,
this.policy,
this.modelMeta,
this.zodSchemas,
this.prismaModule,
model,
this.user,
this.logPrismaQuery
);
return new PolicyProxyHandler(this.prisma, model, this.options, this.context);
}

private requireBackLink(fieldInfo: FieldInfo) {
Expand Down
Loading