From d4bd8ac637e536573a5b710ce3b5173498984e7e Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:57:54 -0700 Subject: [PATCH 1/2] fix: validating currentModel and currentOperation properly --- .../function-invocation-validator.ts | 25 +++- .../test/v2-migrated/issue-1955.test.ts | 100 ++++++++++++++ .../test/v2-migrated/issue-1964.test.ts | 123 +++++++++++++++++ .../test/v2-migrated/issue-1978.test.ts | 39 ++++++ .../test/v2-migrated/issue-1984.test.ts | 56 ++++++++ .../test/v2-migrated/issue-1991.test.ts | 41 ++++++ .../test/v2-migrated/issue-1992.test.ts | 60 ++++++++ .../test/v2-migrated/issue-1993.test.ts | 62 +++++++++ .../test/v2-migrated/issue-1994.test.ts | 102 ++++++++++++++ .../test/v2-migrated/issue-1997.test.ts | 129 ++++++++++++++++++ .../test/v2-migrated/issue-1998.test.ts | 56 ++++++++ 11 files changed, 787 insertions(+), 6 deletions(-) create mode 100644 tests/regression/test/v2-migrated/issue-1955.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1964.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1978.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1984.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1991.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1992.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1993.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1994.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1997.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-1998.test.ts diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index e75c8e3d..4917a0da 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -66,12 +66,7 @@ export default class FunctionInvocationValidator implements AstValidator ExpressionContext.DefaultValue) - .with(P.union('@@allow', '@@deny', '@allow', '@deny'), () => ExpressionContext.AccessPolicy) - .with('@@validate', () => ExpressionContext.ValidationRule) - .with('@@index', () => ExpressionContext.Index) - .otherwise(() => undefined); + const exprContext = this.getExpressionContext(containerAttribute); // get the context allowed for the function const funcAllowedContext = getFunctionExpressionContext(funcDecl); @@ -103,6 +98,24 @@ export default class FunctionInvocationValidator implements AstValidator ExpressionContext.DefaultValue) + .with(P.union('@@allow', '@@deny', '@allow', '@deny'), () => ExpressionContext.AccessPolicy) + .with('@@index', () => ExpressionContext.Index) + .otherwise(() => undefined); + } + + private isValidationAttribute(attr: DataModelAttribute | DataFieldAttribute) { + return attr.decl.ref?.attributes.some((attr) => attr.decl.$refText === '@@@validation'); + } + private validateArgs(funcDecl: FunctionDecl, args: Argument[], accept: ValidationAcceptor) { let success = true; for (let i = 0; i < funcDecl.params.length; i++) { diff --git a/tests/regression/test/v2-migrated/issue-1955.test.ts b/tests/regression/test/v2-migrated/issue-1955.test.ts new file mode 100644 index 00000000..839c1103 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1955.test.ts @@ -0,0 +1,100 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 1955', () => { + it('simple policy', async () => { + const db = await createPolicyTestClient( + ` + model Post { + id Int @id @default(autoincrement()) + name String + expections String[] + @@allow('all', true) + } + `, + { provider: 'postgresql' }, + ); + + await expect( + db.post.createManyAndReturn({ + data: [ + { + name: 'bla', + }, + { + name: 'blu', + }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'bla' }), + expect.objectContaining({ name: 'blu' }), + ]), + ); + + await expect( + db.post.updateManyAndReturn({ + data: { name: 'foo' }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'foo' }), + expect.objectContaining({ name: 'foo' }), + ]), + ); + }); + + it('complex policy', async () => { + const db = await createPolicyTestClient( + ` + model Post { + id Int @id @default(autoincrement()) + name String + expections String[] + comments Comment[] + + @@allow('create', true) + @@allow('read,update', comments^[private]) + } + + model Comment { + id Int @id @default(autoincrement()) + private Boolean @default(false) + postId Int + post Post @relation(fields: [postId], references: [id]) + } + `, + { provider: 'postgresql' }, + ); + + await expect( + db.post.createManyAndReturn({ + data: [ + { + name: 'bla', + }, + { + name: 'blu', + }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'bla' }), + expect.objectContaining({ name: 'blu' }), + ]), + ); + + await expect( + db.post.updateManyAndReturn({ + data: { name: 'foo' }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'foo' }), + expect.objectContaining({ name: 'foo' }), + ]), + ); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1964.test.ts b/tests/regression/test/v2-migrated/issue-1964.test.ts new file mode 100644 index 00000000..b37835dd --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1964.test.ts @@ -0,0 +1,123 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 1964', () => { + it('regression1', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + orgId String +} + +model Author { + id Int @id @default(autoincrement()) + orgId String + name String + posts Post[] + + @@unique([orgId, name]) + @@allow('all', auth().orgId == orgId) +} + +model Post { + id Int @id @default(autoincrement()) + orgId String + title String + author Author @relation(fields: [authorId], references: [id]) + authorId Int + + @@allow('all', auth().orgId == orgId) +} + `, + ); + + const authDb = db.$setAuth({ id: 1, orgId: 'org' }); + + const newauthor = await authDb.author.create({ + data: { + name: `Foo ${Date.now()}`, + orgId: 'org', + posts: { + createMany: { data: [{ title: 'Hello', orgId: 'org' }] }, + }, + }, + include: { posts: true }, + }); + + await expect( + authDb.author.update({ + where: { orgId_name: { orgId: 'org', name: newauthor.name } }, + data: { + name: `Bar ${Date.now()}`, + posts: { deleteMany: { id: { equals: newauthor.posts[0].id } } }, + }, + }), + ).toResolveTruthy(); + }); + + it('regression2', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + slug String @unique + profile Profile? + @@allow('all', true) +} + +model Profile { + id Int @id @default(autoincrement()) + slug String @unique + name String + addresses Address[] + userId Int? @unique + user User? @relation(fields: [userId], references: [id]) + @@allow('all', true) +} + +model Address { + id Int @id @default(autoincrement()) + profileId Int @unique + profile Profile @relation(fields: [profileId], references: [id]) + city String + @@allow('all', true) +} + `, + ); + + const authDb = db.$setAuth({ id: 1, orgId: 'org' }); + + await authDb.user.create({ + data: { + slug: `user1`, + profile: { + create: { + name: `My Profile`, + slug: 'profile1', + addresses: { + create: { id: 1, city: 'City' }, + }, + }, + }, + }, + }); + + await expect( + authDb.user.update({ + where: { slug: 'user1' }, + data: { + profile: { + update: { + addresses: { + deleteMany: { id: { equals: 1 } }, + }, + }, + }, + }, + }), + ).toResolveTruthy(); + + await expect(authDb.address.count()).resolves.toEqual(0); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1978.test.ts b/tests/regression/test/v2-migrated/issue-1978.test.ts new file mode 100644 index 00000000..2a67c670 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1978.test.ts @@ -0,0 +1,39 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('regression', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + posts Post[] + secret String @allow('read', posts?[published]) + @@allow('all', true) +} + +model Post { + id Int @id + author User @relation(fields: [authorId], references: [id]) + authorId Int + published Boolean @default(false) + @@allow('all', true) +} + `, + ); + + await db.$unuseAll().user.create({ + data: { id: 1, secret: 'secret', posts: { create: { id: 1, published: true } } }, + }); + await db.$unuseAll().user.create({ + data: { id: 2, secret: 'secret' }, + }); + + await expect(db.user.findFirst({ where: { id: 1 } })).resolves.toMatchObject({ secret: 'secret' }); + await expect(db.user.findFirst({ where: { id: 1 }, select: { id: true } })).resolves.toEqual({ id: 1 }); + + let r = await db.user.findFirst({ where: { id: 2 } }); + expect(r.secret).toBeUndefined(); + r = await db.user.findFirst({ where: { id: 2 }, select: { id: true } }); + expect(r.secret).toBeUndefined(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1984.test.ts b/tests/regression/test/v2-migrated/issue-1984.test.ts new file mode 100644 index 00000000..ac1a3d9b --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1984.test.ts @@ -0,0 +1,56 @@ +import { createPolicyTestClient, loadSchemaWithError } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 1984', () => { + it('regression1', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + access String + + @@allow('all', + contains(auth().access, currentModel()) || + contains(auth().access, currentOperation())) +} + `, + ); + + const db1 = db; + await expect(db1.user.create({ data: { access: 'foo' } })).toBeRejectedByPolicy(); + + const db2 = db.$setAuth({ id: 1, access: 'aUser' }); + await expect(db2.user.create({ data: { access: 'aUser' } })).toResolveTruthy(); + + const db3 = db.$setAuth({ id: 1, access: 'do-create-read' }); + await expect(db3.user.create({ data: { access: 'do-create-read' } })).toResolveTruthy(); + + const db4 = db.$setAuth({ id: 1, access: 'do-read' }); + await expect(db4.user.create({ data: { access: 'do-read' } })).toBeRejectedByPolicy(); + }); + + it('regression2', async () => { + await loadSchemaWithError( + ` +model User { + id Int @id @default(autoincrement()) + modelName String + @@validate(contains(modelName, currentModel())) +} + `, + 'function "currentModel" is not allowed in the current context: ValidationRule', + ); + }); + + it('regression3', async () => { + await loadSchemaWithError( + ` +model User { + id Int @id @default(autoincrement()) + modelName String @contains(currentModel()) +} + `, + 'function "currentModel" is not allowed in the current context: ValidationRule', + ); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1991.test.ts b/tests/regression/test/v2-migrated/issue-1991.test.ts new file mode 100644 index 00000000..dd92a16e --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1991.test.ts @@ -0,0 +1,41 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1991', async () => { + await createPolicyTestClient( + ` +type FooMetadata { + isLocked Boolean +} + +type FooOptionMetadata { + color String +} + +model Foo { + id String @id @db.Uuid @default(uuid()) + meta FooMetadata @json +} + +model FooOption { + id String @id @db.Uuid @default(uuid()) + meta FooOptionMetadata @json +} + `, + { + provider: 'postgresql', + extraSourceFiles: { + main: ` + import { ZenStackClient } from '@zenstackhq/runtime'; + import { schema } from './schema'; + + const db = new ZenStackClient(schema, {} as any); + + db.fooOption.create({ + data: { meta: { color: 'red' } } + }) + `, + }, + }, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1992.test.ts b/tests/regression/test/v2-migrated/issue-1992.test.ts new file mode 100644 index 00000000..4ca6b7d8 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1992.test.ts @@ -0,0 +1,60 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 1992', async () => { + await loadSchema( + ` +enum MyAppUserType { + Local + Google + Microsoft +} + +model MyAppCompany { + id String @id @default(cuid()) + name String + users MyAppUser[] + + userFolders MyAppUserFolder[] +} + +model MyAppUser { + id String @id @default(cuid()) + companyId String + type MyAppUserType + + @@delegate(type) + + company MyAppCompany @relation(fields: [companyId], references: [id]) + userFolders MyAppUserFolder[] +} + +model MyAppUserLocal extends MyAppUser { + email String + password String +} + +model MyAppUserGoogle extends MyAppUser { + googleId String +} + +model MyAppUserMicrosoft extends MyAppUser { + microsoftId String +} + +model MyAppUserFolder { + id String @id @default(cuid()) + companyId String + userId String + path String + name String + + @@unique([companyId, userId, name]) + @@unique([companyId, userId, path]) + + company MyAppCompany @relation(fields: [companyId], references: [id]) + user MyAppUser @relation(fields: [userId], references: [id]) +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-1993.test.ts b/tests/regression/test/v2-migrated/issue-1993.test.ts new file mode 100644 index 00000000..3445e465 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1993.test.ts @@ -0,0 +1,62 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: zod support +it.skip('verifies issue 1993', async () => { + const { zodSchemas } = await createTestClient( + ` +enum UserType { + UserLocal + UserGoogle +} + +model User { + id String @id @default(cuid()) + companyId String? + type UserType + + @@delegate(type) + + userFolders UserFolder[] + + @@allow('all', true) +} + +model UserLocal extends User { + email String + password String +} + +model UserGoogle extends User { + googleId String +} + +model UserFolder { + id String @id @default(cuid()) + userId String + path String + + user User @relation(fields: [userId], references: [id]) + + @@allow('all', true) +} `, + ); + + expect( + zodSchemas.input.UserLocalInputSchema.create.safeParse({ + data: { + email: 'test@example.com', + password: 'password', + }, + }), + ).toMatchObject({ success: true }); + + expect( + zodSchemas.input.UserFolderInputSchema.create.safeParse({ + data: { + path: '/', + userId: '1', + }, + }), + ).toMatchObject({ success: true }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1994.test.ts b/tests/regression/test/v2-migrated/issue-1994.test.ts new file mode 100644 index 00000000..f072bdb3 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1994.test.ts @@ -0,0 +1,102 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1994', async () => { + const db = await createTestClient( + ` +model OrganizationRole { + id Int @id @default(autoincrement()) + rolePrivileges OrganizationRolePrivilege[] + type String + @@delegate(type) +} + +model Organization { + id Int @id @default(autoincrement()) + customRoles CustomOrganizationRole[] +} + +// roles common to all orgs, defined once +model SystemDefinedRole extends OrganizationRole { + name String @unique +} + +// roles specific to each org +model CustomOrganizationRole extends OrganizationRole { + name String + organizationId Int + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([organizationId, name]) + @@index([organizationId]) +} + +model OrganizationRolePrivilege { + organizationRoleId Int + privilegeId Int + + organizationRole OrganizationRole @relation(fields: [organizationRoleId], references: [id]) + privilege Privilege @relation(fields: [privilegeId], references: [id]) + + @@id([organizationRoleId, privilegeId]) +} + +model Privilege { + id Int @id @default(autoincrement()) + name String // e.g. "org:manage" + + orgRolePrivileges OrganizationRolePrivilege[] + @@unique([name]) +} + `, + { + extraSourceFiles: { + main: ` + import { ZenStackClient } from '@zenstackhq/runtime'; + import { schema } from './schema'; + + const db = new ZenStackClient(schema, {} as any); + + async function main() { + const privilege = await db.privilege.create({ + data: { name: 'org:manage' }, + }); + + await db.systemDefinedRole.create({ + data: { + name: 'Admin', + rolePrivileges: { + create: [ + { + privilegeId: privilege.id, + }, + ], + }, + }, + }); + } + main() + `, + }, + }, + ); + + const privilege = await db.privilege.create({ + data: { name: 'org:manage' }, + }); + + await expect( + db.systemDefinedRole.create({ + data: { + name: 'Admin', + rolePrivileges: { + create: [ + { + privilegeId: privilege.id, + }, + ], + }, + }, + }), + ).toResolveTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-1997.test.ts b/tests/regression/test/v2-migrated/issue-1997.test.ts new file mode 100644 index 00000000..591694bd --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1997.test.ts @@ -0,0 +1,129 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1997', async () => { + const db = await createPolicyTestClient( + ` + model Tenant { + id String @id @default(uuid()) + + users User[] + posts Post[] + comments Comment[] + postUserLikes PostUserLikes[] + } + + 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 Post { + tenantId String @default(auth().tenantId) + tenant Tenant @relation(fields: [tenantId], references: [id]) + id String @default(uuid()) + author User @relation(fields: [authorId], references: [id]) + authorId String @default(auth().id) + + comments Comment[] + likes PostUserLikes[] + + @@id([tenantId, id]) + + @@allow('all', true) + } + + model PostUserLikes { + tenantId String @default(auth().tenantId) + tenant Tenant @relation(fields: [tenantId], references: [id]) + id String @default(uuid()) + + userId String + user User @relation(fields: [userId], references: [id]) + + postId String + post Post @relation(fields: [tenantId, postId], references: [tenantId, id]) + + @@id([tenantId, id]) + @@unique([tenantId, userId, postId]) + + @@allow('all', true) + } + + model Comment { + tenantId String @default(auth().tenantId) + tenant Tenant @relation(fields: [tenantId], references: [id]) + id String @default(uuid()) + postId String + post Post @relation(fields: [tenantId, postId], references: [tenantId, id]) + + @@id([tenantId, 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 }); + + await expect( + authDb.post.create({ + data: { + likes: { + createMany: { + data: [ + { + userId: user.id, + }, + ], + }, + }, + }, + include: { + likes: true, + }, + }), + ).resolves.toMatchObject({ + authorId: user.id, + likes: [ + { + tenantId: tenant.id, + userId: user.id, + }, + ], + }); + + await expect( + authDb.post.create({ + data: { + comments: { + createMany: { + data: [{}], + }, + }, + }, + include: { + comments: true, + }, + }), + ).resolves.toMatchObject({ + authorId: user.id, + comments: [ + { + tenantId: tenant.id, + }, + ], + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-1998.test.ts b/tests/regression/test/v2-migrated/issue-1998.test.ts new file mode 100644 index 00000000..038278b0 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-1998.test.ts @@ -0,0 +1,56 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 1998', async () => { + const db = await createPolicyTestClient( + ` +model Entity { + id String @id + type String + updatable Boolean + children Relation[] @relation("children") + parents Relation[] @relation("parents") + + @@delegate(type) + @@allow('create,read', true) + @@allow('update', updatable) +} + +model A extends Entity {} + +model B extends Entity {} + +model Relation { + parent Entity @relation("children", fields: [parentId], references: [id]) + parentId String + child Entity @relation("parents", fields: [childId], references: [id]) + childId String + + @@allow('create', true) + @@allow('read', check(parent, 'read') && check(child, 'read')) + @@allow('delete', check(parent, 'update') && check(child, 'update')) + + @@id([parentId, childId]) +} + `, + ); + + await db.a.create({ data: { id: '1', updatable: true } }); + await db.b.create({ data: { id: '2', updatable: true } }); + await db.relation.create({ data: { parentId: '1', childId: '2' } }); + + await expect( + db.relation.deleteMany({ + where: { parentId: '1', childId: '2' }, + }), + ).resolves.toEqual({ count: 1 }); + + await db.a.create({ data: { id: '3', updatable: false } }); + await db.b.create({ data: { id: '4', updatable: false } }); + await db.relation.create({ data: { parentId: '3', childId: '4' } }); + await expect( + db.relation.deleteMany({ + where: { parentId: '3', childId: '4' }, + }), + ).resolves.toEqual({ count: 0 }); +}); From 0333b75678bde8215cfc0ec4092e7a2402391e4e Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:14:15 -0700 Subject: [PATCH 2/2] update --- .../language/src/validators/function-invocation-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index 4917a0da..ae759904 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -113,7 +113,7 @@ export default class FunctionInvocationValidator implements AstValidator attr.decl.$refText === '@@@validation'); + return !!attr.decl.ref?.attributes.some((attr) => attr.decl.$refText === '@@@validation'); } private validateArgs(funcDecl: FunctionDecl, args: Argument[], accept: ValidationAcceptor) {