From 7b06d4bee58929474f477601445a23605bfbeedc Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:29:01 -0700 Subject: [PATCH 1/4] feat(validation): add API to suppress validation --- packages/runtime/src/client/client-impl.ts | 10 +- packages/runtime/src/client/contract.ts | 6 ++ .../src/client/crud/validator/index.ts | 49 +++++++--- packages/runtime/src/client/options.ts | 6 ++ .../orm/validation/custom-validation.test.ts | 47 ++++++++++ .../test/v2-migrated/issue-2000.test.ts | 66 ++++++++++++++ .../test/v2-migrated/issue-2007.test.ts | 91 +++++++++++++++++++ .../test/v2-migrated/issue-2019.test.ts | 85 +++++++++++++++++ .../test/v2-migrated/issue-2025.test.ts | 38 ++++++++ 9 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 tests/regression/test/v2-migrated/issue-2000.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2007.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2019.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2025.test.ts diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index c0585455..bcb753a3 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -288,6 +288,14 @@ export class ClientImpl { return this.auth; } + $setInputValidation(enable: boolean) { + const newOptions: ClientOptions = { + ...this.options, + validateInput: enable, + }; + return new ClientImpl(this.schema, newOptions, this); + } + $executeRaw(query: TemplateStringsArray, ...values: any[]) { return createZenStackPromise(async () => { const result = await sql(query, ...values).execute(this.kysely); @@ -325,7 +333,7 @@ export class ClientImpl { } function createClientProxy(client: ClientImpl): ClientImpl { - const inputValidator = new InputValidator(client.$schema); + const inputValidator = new InputValidator(client as unknown as ClientContract); const resultProcessor = new ResultProcessor(client.$schema, client.$options); return new Proxy(client, { diff --git a/packages/runtime/src/client/contract.ts b/packages/runtime/src/client/contract.ts index 2374bc6e..d2c19cb1 100644 --- a/packages/runtime/src/client/contract.ts +++ b/packages/runtime/src/client/contract.ts @@ -103,6 +103,12 @@ export type ClientContract = { */ $setAuth(auth: AuthType | undefined): ClientContract; + /** + * Returns a new client enabling/disabling input validations expressed with attributes like + * `@email`, `@regex`, `@@validate`, etc. + */ + $setInputValidation(enable: boolean): ClientContract; + /** * The Kysely query builder instance. */ diff --git a/packages/runtime/src/client/crud/validator/index.ts b/packages/runtime/src/client/crud/validator/index.ts index fd3be7ac..390323f3 100644 --- a/packages/runtime/src/client/crud/validator/index.ts +++ b/packages/runtime/src/client/crud/validator/index.ts @@ -16,6 +16,7 @@ import { enumerate } from '../../../utils/enumerate'; import { extractFields } from '../../../utils/object-utils'; import { formatError } from '../../../utils/zod-utils'; import { AGGREGATE_OPERATORS, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../../constants'; +import type { ClientContract } from '../../contract'; import { type AggregateArgs, type CountArgs, @@ -53,7 +54,15 @@ type GetSchemaFunc = (model: GetModels { private schemaCache = new Map(); - constructor(private readonly schema: Schema) {} + constructor(private readonly client: ClientContract) {} + + private get schema() { + return this.client.$schema; + } + + private get extraValidationsEnabled() { + return this.client.$options.validateInput !== false; + } validateFindArgs(model: GetModels, args: unknown, options: { unique: boolean; findOne: boolean }) { return this.validate< @@ -251,23 +260,37 @@ export class InputValidator { return this.makeTypeDefSchema(type); } else { return match(type) - .with('String', () => addStringValidation(z.string(), attributes)) - .with('Int', () => addNumberValidation(z.number().int(), attributes)) - .with('Float', () => addNumberValidation(z.number(), attributes)) + .with('String', () => + this.extraValidationsEnabled ? addStringValidation(z.string(), attributes) : z.string(), + ) + .with('Int', () => + this.extraValidationsEnabled ? addNumberValidation(z.number().int(), attributes) : z.number().int(), + ) + .with('Float', () => + this.extraValidationsEnabled ? addNumberValidation(z.number(), attributes) : z.number(), + ) .with('Boolean', () => z.boolean()) .with('BigInt', () => z.union([ - addNumberValidation(z.number().int(), attributes), - addBigIntValidation(z.bigint(), attributes), - ]), - ) - .with('Decimal', () => - z.union([ - addNumberValidation(z.number(), attributes), - addDecimalValidation(z.instanceof(Decimal), attributes), - addDecimalValidation(z.string(), attributes), + this.extraValidationsEnabled + ? addNumberValidation(z.number().int(), attributes) + : z.number().int(), + this.extraValidationsEnabled ? addBigIntValidation(z.bigint(), attributes) : z.bigint(), ]), ) + .with('Decimal', () => { + const options: [z.ZodSchema, z.ZodSchema, ...z.ZodSchema[]] = [ + z.number(), + z.instanceof(Decimal), + z.string(), + ]; + if (this.extraValidationsEnabled) { + for (let i = 0; i < options.length; i++) { + options[i] = addDecimalValidation(options[i]!, attributes); + } + } + return z.union(options); + }) .with('DateTime', () => z.union([z.date(), z.string().datetime()])) .with('Bytes', () => z.instanceof(Uint8Array)) .otherwise(() => z.unknown()); diff --git a/packages/runtime/src/client/options.ts b/packages/runtime/src/client/options.ts index f09f44d6..2f6f7af7 100644 --- a/packages/runtime/src/client/options.ts +++ b/packages/runtime/src/client/options.ts @@ -72,6 +72,12 @@ export type ClientOptions = { * @see https://github.com/brianc/node-postgres/issues/429 */ fixPostgresTimezone?: boolean; + + /** + * Whether to enable input validations expressed with attributes like `@email`, `@regex`, + * `@@validate`, etc. Defaults to `true`. + */ + validateInput?: boolean; } & (HasComputedFields extends true ? { /** diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 35667e4c..596beefa 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -108,4 +108,51 @@ describe('Custom validation tests', () => { ).toResolveTruthy(); } }); + + it('allows disabling validation', async () => { + const db = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + email String @unique @email + @@allow('all', true) + } + `, + ); + + await expect( + db.user.create({ + data: { + email: 'xyz', + }, + }), + ).toBeRejectedByValidation(); + + await expect( + db.$setInputValidation(false).user.create({ + data: { + id: 1, + email: 'xyz', + }, + }), + ).toResolveTruthy(); + + await expect( + db.$setInputValidation(false).user.update({ + where: { id: 1 }, + data: { + email: 'abc', + }, + }), + ).toResolveTruthy(); + + // original client not affected + await expect( + db.user.create({ + data: { + email: 'xyz', + }, + }), + ).toBeRejectedByValidation(); + }); }); diff --git a/tests/regression/test/v2-migrated/issue-2000.test.ts b/tests/regression/test/v2-migrated/issue-2000.test.ts new file mode 100644 index 00000000..009657a1 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2000.test.ts @@ -0,0 +1,66 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 2000', async () => { + const db = await createPolicyTestClient( + ` +type Base { + id String @id @default(uuid()) @deny('update', true) + createdAt DateTime @default(now()) @deny('update', true) + updatedAt DateTime @updatedAt @deny('update', true) + active Boolean @default(false) + published Boolean @default(true) + deleted Boolean @default(false) + startDate DateTime? + endDate DateTime? + + @@allow('create', true) + @@allow('read', true) + @@allow('update', true) +} + +enum EntityType { + User + Alias + Group + Service + Device + Organization + Guest +} + +model Entity with Base { + entityType EntityType + name String? @unique + members Entity[] @relation("members") + memberOf Entity[] @relation("members") + @@delegate(entityType) + + + @@allow('create', true) + @@allow('read', true) + @@allow('update', true) + @@validate(!active || (active && name != null), "Active Entities Must Have A Name") +} + +model User extends Entity { + profile Json? + username String @unique + password String @password + + @@allow('create', true) + @@allow('read', true) + @@allow('update', true) +} + `, + ); + + await expect(db.user.create({ data: { username: 'admin', password: 'abc12345' } })).toResolveTruthy(); + await expect( + db.user.update({ where: { username: 'admin' }, data: { password: 'abc123456789123' } }), + ).toResolveTruthy(); + + // violating validation rules + await expect(db.user.update({ where: { username: 'admin' }, data: { active: true } })).toBeRejectedByPolicy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-2007.test.ts b/tests/regression/test/v2-migrated/issue-2007.test.ts new file mode 100644 index 00000000..c67d12de --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2007.test.ts @@ -0,0 +1,91 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// TODO: field-level policy support +describe.skip('Regression for issue 2007', () => { + it('regression1', async () => { + const db = await createPolicyTestClient( + ` +model Page { + id String @id @default(cuid()) + title String + + images Image[] + + @@allow('all', true) +} + +model Image { + id String @id @default(cuid()) @deny('update', true) + url String + pageId String? + page Page? @relation(fields: [pageId], references: [id]) + + @@allow('all', true) +} + `, + ); + + const image = await db.image.create({ + data: { + url: 'https://example.com/image.png', + }, + }); + + await expect( + db.image.update({ + where: { id: image.id }, + data: { + page: { + create: { + title: 'Page 1', + }, + }, + }, + }), + ).toResolveTruthy(); + }); + + it('regression2', async () => { + const db = await createPolicyTestClient( + ` + model Page { + id String @id @default(cuid()) + title String + + images Image[] + + @@allow('all', true) + } + + model Image { + id String @id @default(cuid()) + url String + pageId String? @deny('update', true) + page Page? @relation(fields: [pageId], references: [id]) + + @@allow('all', true) + } + `, + ); + + const image = await db.image.create({ + data: { + url: 'https://example.com/image.png', + }, + }); + + await expect( + db.image.update({ + where: { id: image.id }, + data: { + page: { + create: { + title: 'Page 1', + }, + }, + }, + }), + ).toBeRejectedByPolicy(); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-2019.test.ts b/tests/regression/test/v2-migrated/issue-2019.test.ts new file mode 100644 index 00000000..d25ab518 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2019.test.ts @@ -0,0 +1,85 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2019', async () => { + const db = await createPolicyTestClient( + ` +model Tenant { + id String @id @default(uuid()) + + users User[] + content Content[] +} + +model User { + id String @id @default(uuid()) + tenantId String @default(auth().tenantId) + tenant Tenant @relation(fields: [tenantId], references: [id]) + posts Post[] + likes PostUserLikes[] + + @@allow('all', true) +} + +model Content { + tenantId String @default(auth().tenantId) + tenant Tenant @relation(fields: [tenantId], references: [id]) + id String @id @default(uuid()) + contentType String + + @@delegate(contentType) + @@allow('all', true) +} + +model Post extends Content { + author User @relation(fields: [authorId], references: [id]) + authorId String @default(auth().id) + + comments Comment[] + likes PostUserLikes[] + + @@allow('all', true) +} + +model PostUserLikes extends Content { + userId String + user User @relation(fields: [userId], references: [id]) + + postId String + post Post @relation(fields: [postId], references: [id]) + + @@unique([userId, postId]) + + @@allow('all', true) +} + +model Comment extends Content { + postId String + post Post @relation(fields: [postId], references: [id]) + + @@allow('all', true) +} + `, + ); + + const tenant = await db.$unuseAll().tenant.create({ data: {} }); + const user = await db.$unuseAll().user.create({ data: { tenantId: tenant.id } }); + const authDb = db.$setAuth({ id: user.id, tenantId: tenant.id }); + const result = await authDb.post.create({ + data: { + likes: { + createMany: { + data: [ + { + userId: user.id, + }, + ], + }, + }, + }, + include: { + likes: true, + }, + }); + expect(result.likes[0].tenantId).toBe(tenant.id); +}); diff --git a/tests/regression/test/v2-migrated/issue-2025.test.ts b/tests/regression/test/v2-migrated/issue-2025.test.ts new file mode 100644 index 00000000..42da8fc4 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2025.test.ts @@ -0,0 +1,38 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2025', async () => { + const db = await createTestClient( + ` + model User { + id String @id @default(cuid()) + email String @unique @email + termsAndConditions Int? + @@allow('all', true) + } + `, + ); + + await expect( + db.user.create({ + data: { + email: 'xyz', + }, + }), + ).toBeRejectedByValidation(); + + const user = await db.$setInputValidation(false).user.create({ + data: { + email: 'xyz', + }, + }); + + await expect( + db.user.update({ + where: { id: user.id }, + data: { + termsAndConditions: 1, + }, + }), + ).toResolveTruthy(); +}); From 3cbf00440c17ad688fe81a51f63a891e49e9cbd7 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:37:35 -0700 Subject: [PATCH 2/4] fix "@@validate" --- .../runtime/src/client/crud/validator/index.ts | 16 ++++++++++++---- .../orm/validation/custom-validation.test.ts | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/client/crud/validator/index.ts b/packages/runtime/src/client/crud/validator/index.ts index 390323f3..2858e280 100644 --- a/packages/runtime/src/client/crud/validator/index.ts +++ b/packages/runtime/src/client/crud/validator/index.ts @@ -936,8 +936,12 @@ export class InputValidator { } }); - const uncheckedCreateSchema = addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes); - const checkedCreateSchema = addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes); + const uncheckedCreateSchema = this.extraValidationsEnabled + ? addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes) + : z.strictObject(uncheckedVariantFields); + const checkedCreateSchema = this.extraValidationsEnabled + ? addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes) + : z.strictObject(checkedVariantFields); if (!hasRelation) { return this.orArray(uncheckedCreateSchema, canBeArray); @@ -1216,8 +1220,12 @@ export class InputValidator { } }); - const uncheckedUpdateSchema = addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes); - const checkedUpdateSchema = addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes); + const uncheckedUpdateSchema = this.extraValidationsEnabled + ? addCustomValidation(z.strictObject(uncheckedVariantFields), modelDef.attributes) + : z.strictObject(uncheckedVariantFields); + const checkedUpdateSchema = this.extraValidationsEnabled + ? addCustomValidation(z.strictObject(checkedVariantFields), modelDef.attributes) + : z.strictObject(checkedVariantFields); if (!hasRelation) { return uncheckedUpdateSchema; } else { diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 596beefa..edd0c00e 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -115,6 +115,7 @@ describe('Custom validation tests', () => { model User { id Int @id @default(autoincrement()) email String @unique @email + @@validate(length(email, 8)) @@allow('all', true) } `, @@ -127,6 +128,13 @@ describe('Custom validation tests', () => { }, }), ).toBeRejectedByValidation(); + await expect( + db.user.create({ + data: { + email: 'a@b.com', + }, + }), + ).toBeRejectedByValidation(); await expect( db.$setInputValidation(false).user.create({ @@ -141,7 +149,7 @@ describe('Custom validation tests', () => { db.$setInputValidation(false).user.update({ where: { id: 1 }, data: { - email: 'abc', + email: 'a@b.com', }, }), ).toResolveTruthy(); @@ -154,5 +162,12 @@ describe('Custom validation tests', () => { }, }), ).toBeRejectedByValidation(); + await expect( + db.user.create({ + data: { + email: 'a@b.com', + }, + }), + ).toBeRejectedByValidation(); }); }); From e9cac0a8767e45a24eba865e395e2701ac117dc8 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:50:12 -0700 Subject: [PATCH 3/4] fix --- .../runtime/src/client/crud/validator/index.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/runtime/src/client/crud/validator/index.ts b/packages/runtime/src/client/crud/validator/index.ts index 2858e280..f79894b6 100644 --- a/packages/runtime/src/client/crud/validator/index.ts +++ b/packages/runtime/src/client/crud/validator/index.ts @@ -279,17 +279,13 @@ export class InputValidator { ]), ) .with('Decimal', () => { - const options: [z.ZodSchema, z.ZodSchema, ...z.ZodSchema[]] = [ - z.number(), - z.instanceof(Decimal), - z.string(), - ]; - if (this.extraValidationsEnabled) { - for (let i = 0; i < options.length; i++) { - options[i] = addDecimalValidation(options[i]!, attributes); - } - } - return z.union(options); + return z.union([ + this.extraValidationsEnabled ? addNumberValidation(z.number(), attributes) : z.number(), + this.extraValidationsEnabled + ? addDecimalValidation(z.instanceof(Decimal), attributes) + : z.instanceof(Decimal), + this.extraValidationsEnabled ? addDecimalValidation(z.string(), attributes) : z.string(), + ]); }) .with('DateTime', () => z.union([z.date(), z.string().datetime()])) .with('Bytes', () => z.instanceof(Uint8Array)) From 3385c7573243b5061acfc0d3b96f9effee3a290d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:58:46 -0700 Subject: [PATCH 4/4] update --- packages/runtime/src/client/crud/validator/index.ts | 6 ++---- packages/runtime/src/client/crud/validator/utils.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/client/crud/validator/index.ts b/packages/runtime/src/client/crud/validator/index.ts index f79894b6..ffdd191a 100644 --- a/packages/runtime/src/client/crud/validator/index.ts +++ b/packages/runtime/src/client/crud/validator/index.ts @@ -281,10 +281,8 @@ export class InputValidator { .with('Decimal', () => { return z.union([ this.extraValidationsEnabled ? addNumberValidation(z.number(), attributes) : z.number(), - this.extraValidationsEnabled - ? addDecimalValidation(z.instanceof(Decimal), attributes) - : z.instanceof(Decimal), - this.extraValidationsEnabled ? addDecimalValidation(z.string(), attributes) : z.string(), + addDecimalValidation(z.instanceof(Decimal), attributes, this.extraValidationsEnabled), + addDecimalValidation(z.string(), attributes, this.extraValidationsEnabled), ]); }) .with('DateTime', () => z.union([z.date(), z.string().datetime()])) diff --git a/packages/runtime/src/client/crud/validator/utils.ts b/packages/runtime/src/client/crud/validator/utils.ts index 6b0a17d5..1fdecb25 100644 --- a/packages/runtime/src/client/crud/validator/utils.ts +++ b/packages/runtime/src/client/crud/validator/utils.ts @@ -145,6 +145,7 @@ export function addBigIntValidation(schema: z.ZodBigInt, attributes: AttributeAp export function addDecimalValidation( schema: z.ZodType | z.ZodString, attributes: AttributeApplication[] | undefined, + addExtraValidation: boolean, ): z.ZodSchema { let result: z.ZodSchema = schema; @@ -176,7 +177,7 @@ export function addDecimalValidation( }); } - if (attributes) { + if (attributes && addExtraValidation) { for (const attr of attributes) { const val = getArgValue(attr.args?.[0]?.value); if (val === undefined) {