From 99745a459fef478b63dc8f1c43a837cce85392bd Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:46:29 -0700 Subject: [PATCH] fix(orm): forbid selecting @omit fields when allowQueryTimeOmitOverride is false (#2671) `allowQueryTimeOmitOverride: false` only guarded the query-time `omit` clause, so an omitted field could still be un-omitted (leaked) via an explicit `select: { field: true }`. The select schema now only accepts `false` for config-omitted fields when query-time override is disallowed, rejecting such selects with a validation error. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orm/src/client/zod/factory.ts | 26 ++++++++++- tests/regression/test/issue-2671.test.ts | 59 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/regression/test/issue-2671.test.ts diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index bea3db5ba..42b0b8fb3 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1153,7 +1153,13 @@ export class ZodSchemaFactory< fields[field] = this.makeRelationSelectIncludeSchema(model, field, options).optional(); } } else { - fields[field] = z.boolean().optional(); + if (this.options.allowQueryTimeOmitOverride === false && this.isFieldOmittedByConfig(model, field)) { + // when query-time omit override is disallowed, an omitted field cannot be + // un-omitted by explicitly selecting it, so only allow `false` + fields[field] = z.literal(false).optional(); + } else { + fields[field] = z.boolean().optional(); + } } } @@ -1261,6 +1267,24 @@ export class ZodSchemaFactory< return result; } + /** + * Determines whether a field is configured to be omitted at the schema or client-options level + * (query-level omit is excluded as it's mutually exclusive with `select`). + */ + private isFieldOmittedByConfig(model: string, field: string): boolean { + // options-level omit + const omitConfig = + (this.options.omit as Record | undefined)?.[lowerCaseFirst(model)] ?? + (this.options.omit as Record | undefined)?.[model]; + if (omitConfig && typeof omitConfig[field] === 'boolean') { + return omitConfig[field]; + } + + // schema-level omit + const fieldDef = requireField(this.schema, model, field); + return !!fieldDef.omit; + } + @cache() private makeOmitSchema(model: string) { const fields: Record = {}; diff --git a/tests/regression/test/issue-2671.test.ts b/tests/regression/test/issue-2671.test.ts new file mode 100644 index 000000000..69d0a9dff --- /dev/null +++ b/tests/regression/test/issue-2671.test.ts @@ -0,0 +1,59 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2671 +describe('Regression for issue 2671', () => { + const schema = ` +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String @omit +} + `; + + it('allows selecting omitted fields when override is allowed (default)', async () => { + const db = await createTestClient(schema); + + await db.user.create({ + data: { email: 'user1@test.com', passwordHash: 'secret-hash' }, + }); + + // by default query-time override is allowed, so explicit select returns the field + const selected = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { passwordHash: true }, + }); + expect(selected?.passwordHash).toBe('secret-hash'); + }); + + it('forbids selecting omitted fields when allowQueryTimeOmitOverride is false', async () => { + const base = await createTestClient(schema); + const db = base.$setOptions({ ...base.$options, allowQueryTimeOmitOverride: false }); + + await db.user.create({ + data: { email: 'user1@test.com', passwordHash: 'secret-hash' }, + }); + + // explicitly selecting the omitted field must be rejected + await expect( + db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { passwordHash: true }, + }), + ).toBeRejectedByValidation(); + + // selecting non-omitted fields still works + const ok = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { email: true }, + }); + expect(ok).toEqual({ email: 'user1@test.com' }); + + // explicitly excluding the omitted field via select is fine + const excluded = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { email: true, passwordHash: false }, + }); + expect(excluded).toEqual({ email: 'user1@test.com' }); + }); +});