From 6ba148051cb64c696f5560b231b4200801545625 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:17:26 -0700 Subject: [PATCH 1/2] feat(policy): support comparing `auth()` with auth model --- packages/language/package.json | 7 +- .../src/validators/expression-validator.ts | 4 +- .../test/expression-validationt.test.ts | 100 ++++++++++++++++ packages/language/test/utils.ts | 29 +++-- .../src/client/crud/operations/base.ts | 5 +- packages/runtime/src/client/errors.ts | 4 +- .../plugins/policy/expression-transformer.ts | 6 +- .../runtime/test/policy/auth-equality.test.ts | 109 ++++++++++++++++++ .../runtime/test/policy/ref-equality.test.ts | 40 ------- pnpm-lock.yaml | 3 + 10 files changed, 246 insertions(+), 61 deletions(-) create mode 100644 packages/language/test/expression-validationt.test.ts create mode 100644 packages/runtime/test/policy/auth-equality.test.ts delete mode 100644 packages/runtime/test/policy/ref-equality.test.ts diff --git a/packages/language/package.json b/packages/language/package.json index 7159b0d4..e3da38b0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -59,13 +59,14 @@ }, "devDependencies": { "@types/pluralize": "^0.0.33", + "@types/tmp": "catalog:", + "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", - "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", + "glob": "^11.0.2", "langium-cli": "catalog:", - "tmp": "catalog:", - "@types/tmp": "catalog:" + "tmp": "catalog:" }, "volta": { "node": "18.19.1", diff --git a/packages/language/src/validators/expression-validator.ts b/packages/language/src/validators/expression-validator.ts index 28c15fc6..cf74db06 100644 --- a/packages/language/src/validators/expression-validator.ts +++ b/packages/language/src/validators/expression-validator.ts @@ -207,12 +207,12 @@ export default class ExpressionValidator implements AstValidator { isDataFieldReference(expr.left) && (isThisExpr(expr.right) || isDataFieldReference(expr.right)) ) { - accept('error', 'comparison between model-typed fields are not supported', { node: expr }); + accept('error', 'comparison between models is not supported', { node: expr }); } else if ( isDataFieldReference(expr.right) && (isThisExpr(expr.left) || isDataFieldReference(expr.left)) ) { - accept('error', 'comparison between model-typed fields are not supported', { node: expr }); + accept('error', 'comparison between models is not supported', { node: expr }); } } else if ( (isDataModel(leftType) && !isNullExpr(expr.right)) || diff --git a/packages/language/test/expression-validationt.test.ts b/packages/language/test/expression-validationt.test.ts new file mode 100644 index 00000000..5a3179f3 --- /dev/null +++ b/packages/language/test/expression-validationt.test.ts @@ -0,0 +1,100 @@ +import { describe, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; + +describe('Expression Validation Tests', () => { + it('should reject model comparison', async () => { + await loadSchemaWithError( + ` + model User { + id Int @id + name String + posts Post[] + } + + model Post { + id Int @id + title String + author User @relation(fields: [authorId], references: [id]) + @@allow('all', author == this) + } + `, + 'comparison between models is not supported', + ); + }); + + it('should reject model comparison', async () => { + await loadSchemaWithError( + ` + model User { + id Int @id + name String + profile Profile? + address Address? + @@allow('read', profile == this) + } + + model Profile { + id Int @id + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique + } + + model Address { + id Int @id + street String + user User @relation(fields: [userId], references: [id]) + userId Int @unique + } + `, + 'comparison between models is not supported', + ); + }); + + it('should allow auth comparison with auth type', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + name String + profile Profile? + @@allow('read', auth() == this) + } + + model Profile { + id Int @id + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique + @@allow('read', auth() == user) + } + `, + ); + }); + + it('should reject auth comparison with non-auth type', async () => { + await loadSchemaWithError( + ` + model User { + id Int @id + name String + profile Profile? + } + + model Profile { + id Int @id + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique + @@allow('read', auth() == this) + } + `, + 'incompatible operand types', + ); + }); +}); diff --git a/packages/language/test/utils.ts b/packages/language/test/utils.ts index fe558f41..b14bdabb 100644 --- a/packages/language/test/utils.ts +++ b/packages/language/test/utils.ts @@ -1,16 +1,20 @@ +import { invariant } from '@zenstackhq/common-helpers'; +import { glob } from 'glob'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import fs from 'node:fs'; -import { loadDocument } from '../src'; import { expect } from 'vitest'; -import { invariant } from '@zenstackhq/common-helpers'; +import { loadDocument } from '../src'; export async function loadSchema(schema: string) { // create a temp file const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); fs.writeFileSync(tempFile, schema); - const r = await loadDocument(tempFile); - expect(r.success).toBe(true); + const r = await loadDocument(tempFile, getPluginModels()); + expect(r).toSatisfy( + (r) => r.success, + `Failed to load schema: ${(r as any).errors?.map((e) => e.toString()).join(', ')}`, + ); invariant(r.success); return r.model; } @@ -19,12 +23,21 @@ export async function loadSchemaWithError(schema: string, error: string | RegExp // create a temp file const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); fs.writeFileSync(tempFile, schema); - const r = await loadDocument(tempFile); + const r = await loadDocument(tempFile, getPluginModels()); expect(r.success).toBe(false); invariant(!r.success); if (typeof error === 'string') { - expect(r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase()))).toBe(true); + expect(r).toSatisfy( + (r) => r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase())), + `Expected error message to include "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`, + ); } else { - expect(r.errors.some((e) => error.test(e))).toBe(true); + expect(r).toSatisfy( + (r) => r.errors.some((e) => error.test(e)), + `Expected error message to match "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`, + ); } } +function getPluginModels() { + return glob.sync(path.resolve(__dirname, '../../runtime/src/plugins/**/plugin.zmodel')); +} diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 781e6468..7dd4626e 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -567,7 +567,10 @@ export abstract class BaseOperationHandler { select: fieldsToSelectObject(referencedPkFields) as any, }); if (!relationEntity) { - throw new NotFoundError(`Could not find the entity for connect action`); + throw new NotFoundError( + relationModel, + `Could not find the entity to connect for the relation "${relationField.name}"`, + ); } result = relationEntity; } diff --git a/packages/runtime/src/client/errors.ts b/packages/runtime/src/client/errors.ts index 38c5077b..1d6134e9 100644 --- a/packages/runtime/src/client/errors.ts +++ b/packages/runtime/src/client/errors.ts @@ -25,7 +25,7 @@ export class InternalError extends Error {} * Error thrown when an entity is not found. */ export class NotFoundError extends Error { - constructor(model: string) { - super(`Entity not found for model "${model}"`); + constructor(model: string, details?: string) { + super(`Entity not found for model "${model}"${details ? `: ${details}` : ''}`); } } diff --git a/packages/runtime/src/plugins/policy/expression-transformer.ts b/packages/runtime/src/plugins/policy/expression-transformer.ts index c43a6fb7..ecc4df1f 100644 --- a/packages/runtime/src/plugins/policy/expression-transformer.ts +++ b/packages/runtime/src/plugins/policy/expression-transformer.ts @@ -305,11 +305,7 @@ export class ExpressionTransformer { private _unary(expr: UnaryExpression, context: ExpressionTransformerContext) { // only '!' operator for now invariant(expr.op === '!', 'only "!" operator is supported'); - return BinaryOperationNode.create( - this.transform(expr.operand, context), - this.transformOperator('!='), - trueNode(this.dialect), - ); + return logicalNot(this.transform(expr.operand, context)); } private transformOperator(op: Exclude) { diff --git a/packages/runtime/test/policy/auth-equality.test.ts b/packages/runtime/test/policy/auth-equality.test.ts new file mode 100644 index 00000000..2baf46ed --- /dev/null +++ b/packages/runtime/test/policy/auth-equality.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from './utils'; + +describe('Reference Equality Tests', () => { + it('works with create and auth equality', async () => { + const db = await createPolicyTestClient( + ` +model User { + id1 Int + id2 Int + posts Post[] + @@id([id1, id2]) + @@allow('all', auth() == this) + @@allow('read', true) +} + +model Post { + id Int @id @default(autoincrement()) + title String + authorId1 Int + authorId2 Int + author User @relation(fields: [authorId1, authorId2], references: [id1, id2]) + @@allow('all', auth() == author) +} + `, + ); + + await expect( + db.user.create({ + data: { id1: 1, id2: 2 }, + }), + ).toBeRejectedByPolicy(); + + await expect( + db.$setAuth({ id1: 1, id2: 2 }).user.create({ + data: { id1: 1, id2: 2 }, + }), + ).resolves.toMatchObject({ id1: 1, id2: 2 }); + + await expect( + db.post.create({ + data: { authorId1: 1, authorId2: 2, title: 'Post 1' }, + }), + ).toBeRejectedByPolicy(); + await expect( + db.post.create({ + data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 1' }, + }), + ).toBeRejectedByPolicy(); + + await expect( + db.$setAuth({ id1: 1, id2: 2 }).post.create({ + data: { authorId1: 1, authorId2: 2, title: 'Post 1' }, + }), + ).resolves.toMatchObject({ title: 'Post 1' }); + await expect( + db.$setAuth({ id1: 1, id2: 2 }).post.create({ + data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 2' }, + }), + ).resolves.toMatchObject({ title: 'Post 2' }); + }); + + it('works with create and auth inequality', async () => { + const db = await createPolicyTestClient( + ` +model User { + id1 Int + id2 Int + posts Post[] + @@id([id1, id2]) + @@allow('all', auth() != this) + @@allow('read', true) +} + +model Post { + id Int @id @default(autoincrement()) + title String + authorId1 Int + authorId2 Int + author User @relation(fields: [authorId1, authorId2], references: [id1, id2]) + @@allow('all', auth() != author) + @@allow('read', true) +} + `, + ); + + await expect( + db.$setAuth({ id1: 1, id2: 2 }).user.create({ + data: { id1: 1, id2: 2 }, + }), + ).toBeRejectedByPolicy(); + await expect( + db.$setAuth({ id1: 2, id2: 2 }).user.create({ + data: { id1: 1, id2: 2 }, + }), + ).toResolveTruthy(); + + await expect( + db.$setAuth({ id1: 1, id2: 2 }).post.create({ + data: { authorId1: 1, authorId2: 2, title: 'Post 1' }, + }), + ).toBeRejectedByPolicy(); + await expect( + db.$setAuth({ id1: 2, id2: 2 }).post.create({ + data: { authorId1: 1, authorId2: 2, title: 'Post 1' }, + }), + ).resolves.toMatchObject({ title: 'Post 1' }); + }); +}); diff --git a/packages/runtime/test/policy/ref-equality.test.ts b/packages/runtime/test/policy/ref-equality.test.ts deleted file mode 100644 index 3196f52b..00000000 --- a/packages/runtime/test/policy/ref-equality.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createPolicyTestClient } from './utils'; - -describe('Reference Equality Tests', () => { - it('works with auth equality', async () => { - const db = await createPolicyTestClient( - ` -model User { - id1 Int - id2 Int - posts Post[] - @@id([id1, id2]) - @@allow('all', auth() == this) -} - -model Post { - id Int @id @default(autoincrement()) - title String - authorId1 Int - authorId2 Int - author User @relation(fields: [authorId1, authorId2], references: [id1, id2]) - @@allow('all', auth() == author) -} - `, - { log: ['query'] }, - ); - - await expect( - db.user.create({ - data: { id1: 1, id2: 2 }, - }), - ).toBeRejectedByPolicy(); - - await expect( - db.$setAuth({ id1: 1, id2: 2 }).user.create({ - data: { id1: 1, id2: 2 }, - }), - ).resolves.toMatchObject({ id1: 1, id2: 2 }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82877877..f67f9b78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@zenstackhq/vitest-config': specifier: workspace:* version: link:../vitest-config + glob: + specifier: ^11.0.2 + version: 11.0.2 langium-cli: specifier: 'catalog:' version: 3.5.0 From fd0f8ce75ad20976c0258de63ea011586523563e Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:32:50 -0700 Subject: [PATCH 2/2] fix file name --- ...pression-validationt.test.ts => expression-validation.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/language/test/{expression-validationt.test.ts => expression-validation.test.ts} (100%) diff --git a/packages/language/test/expression-validationt.test.ts b/packages/language/test/expression-validation.test.ts similarity index 100% rename from packages/language/test/expression-validationt.test.ts rename to packages/language/test/expression-validation.test.ts