From 97416ca6d8684132f9eb1c33d824ddbbb99be3dd Mon Sep 17 00:00:00 2001 From: Tural Babayev Date: Thu, 29 Oct 2026 01:58:00 +0300 Subject: [PATCH 1/2] fix(policy): compare create-input enum fields as text on PostgreSQL Create rules are evaluated against a VALUES table built from the input, where enum fields are cast to text (text[] for enum lists). Comparing such a field with a stored native enum column, e.g. `anchor.approvals?[permissions == this.permissions]` or `permission == anchor.defaultPermission`, fails on PostgreSQL with 42883 (operator does not exist: "Permission"[] = text[]). Extend the existing `before()` handling (#2869) to the create input: when either operand is an enum field of the entity being created, both sides are compared as text. Enum arrays keep their order and multiplicity, since PostgreSQL renders enum[] and text[] in the same canonical text form. Fixes #2866 --- .../policy/src/expression-transformer.ts | 27 +++-- tests/regression/test/issue-2866.test.ts | 102 ++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 tests/regression/test/issue-2866.test.ts diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 847e7de46..fbde40015 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -342,9 +342,11 @@ export class ExpressionTransformer { const rightFieldDef = this.getFieldDefFromFieldRef(normalizedRight, context); // Map ZModel operator to SQL operator string const sqlOp = op === '==' ? '=' : op; - // `before()` enums are text in the VALUES table and pg can't compare text to a native enum + // enums read from a VALUES table (`before()` rows, create input) are text, and pg can't + // compare text to a native enum const asText = - this.isBeforeEnumField(normalizedLeft, context) || this.isBeforeEnumField(normalizedRight, context); + this.isValuesTableEnumField(normalizedLeft, context) || + this.isValuesTableEnumField(normalizedRight, context); const operand = (node: OperationNode) => asText ? this.dialect.castText(new ExpressionWrapper(node)) : new ExpressionWrapper(node); return this.dialect @@ -1214,13 +1216,24 @@ export class ExpressionTransformer { return !!fieldDef?.relation; } - private isBeforeEnumField(expr: Expression, context: ExpressionTransformerContext) { - if (!ExpressionUtils.isMember(expr) || !isBeforeInvocation(expr.receiver)) { + private isValuesTableEnumField(expr: Expression, context: ExpressionTransformerContext) { + let field: string | undefined; + if (ExpressionUtils.isMember(expr) && isBeforeInvocation(expr.receiver)) { + // `before()` always refers to the model being updated (`thisType`), even inside + // collection predicates where `modelOrType` is the related model + field = expr.members[0]; + } else if (context.operation === 'create') { + // create rules read the new entity's own fields from the input VALUES table + if (ExpressionUtils.isMember(expr) && ExpressionUtils.isThis(expr.receiver) && expr.members.length === 1) { + field = expr.members[0]; + } else if (ExpressionUtils.isField(expr) && context.modelOrType === context.thisType) { + field = expr.field; + } + } + if (!field) { return false; } - // `before()` always refers to the model being updated (`thisType`), even inside - // collection predicates where `modelOrType` is the related model - const fieldDef = QueryUtils.getField(this.schema, context.thisType, expr.members[0]!); + const fieldDef = QueryUtils.getField(this.schema, context.thisType, field); return !!fieldDef && QueryUtils.isEnum(this.schema, fieldDef.type); } diff --git a/tests/regression/test/issue-2866.test.ts b/tests/regression/test/issue-2866.test.ts new file mode 100644 index 000000000..ad6c02590 --- /dev/null +++ b/tests/regression/test/issue-2866.test.ts @@ -0,0 +1,102 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2866 +describe('Regression for issue #2866', () => { + const schema = ` +enum Permission { + VIEW + EDIT +} + +model Anchor { + id Int @id + defaultPermission Permission @default(VIEW) + approvals Approval[] + roles Role[] + grants Grant[] + @@allow('all', true) +} + +model Approval { + id Int @id + anchorId Int + anchor Anchor @relation(fields: [anchorId], references: [id]) + permission Permission @default(VIEW) + permissions Permission[] + @@allow('all', true) +} + +model Role { + id Int @id + anchorId Int + anchor Anchor @relation(fields: [anchorId], references: [id]) + permissions Permission[] + @@allow('read', true) + @@allow('create', anchor.approvals?[permissions == this.permissions]) + @@allow('update', anchor.approvals?[permissions != this.permissions]) +} + +model Grant { + id Int @id + anchorId Int + anchor Anchor @relation(fields: [anchorId], references: [id]) + permission Permission + @@allow('read', true) + @@allow('create', permission == anchor.defaultPermission || anchor.approvals?[permission == this.permission]) +} +`; + + async function setup() { + const db = await createPolicyTestClient(schema, { provider: 'postgresql', usePrismaPush: true }); + const raw = db.$unuseAll(); + await raw.anchor.create({ data: { id: 1 } }); + await raw.anchor.create({ data: { id: 2 } }); + await raw.approval.create({ data: { id: 1, anchorId: 1, permissions: ['VIEW', 'EDIT'] } }); + await raw.approval.create({ data: { id: 2, anchorId: 2, permission: 'EDIT' } }); + return db; + } + + it('compares a stored enum array with the create input', async () => { + const db = await setup(); + + await expect( + db.role.create({ data: { id: 1, anchorId: 1, permissions: ['VIEW', 'EDIT'] } }), + ).resolves.toMatchObject({ permissions: ['VIEW', 'EDIT'] }); + + // array equality keeps order and multiplicity + await expect( + db.role.create({ data: { id: 2, anchorId: 1, permissions: ['EDIT', 'VIEW'] } }), + ).toBeRejectedByPolicy(); + await expect(db.role.create({ data: { id: 3, anchorId: 1, permissions: ['VIEW'] } })).toBeRejectedByPolicy(); + await expect( + db.role.create({ data: { id: 4, anchorId: 1, permissions: ['VIEW', 'EDIT', 'EDIT'] } }), + ).toBeRejectedByPolicy(); + }); + + it('compares stored enum arrays on update', async () => { + const db = await setup(); + const raw = db.$unuseAll(); + await raw.role.create({ data: { id: 1, anchorId: 1, permissions: ['VIEW', 'EDIT'] } }); + await raw.role.create({ data: { id: 2, anchorId: 1, permissions: ['VIEW'] } }); + + await expect(db.role.update({ where: { id: 1 }, data: { permissions: ['EDIT'] } })).toBeRejectedNotFound(); + await expect(db.role.update({ where: { id: 2 }, data: { permissions: ['EDIT'] } })).resolves.toMatchObject({ + permissions: ['EDIT'], + }); + }); + + it('compares a stored enum with the create input', async () => { + const db = await setup(); + + // top-level field compared with a relation field + await expect(db.grant.create({ data: { id: 1, anchorId: 1, permission: 'VIEW' } })).resolves.toMatchObject({ + permission: 'VIEW', + }); + // `this` field compared inside a collection predicate + await expect(db.grant.create({ data: { id: 2, anchorId: 2, permission: 'EDIT' } })).resolves.toMatchObject({ + permission: 'EDIT', + }); + await expect(db.grant.create({ data: { id: 3, anchorId: 1, permission: 'EDIT' } })).toBeRejectedByPolicy(); + }); +}); From 7d78ed2ff6628822dc0e3aeb56d319d0c5a50432 Mon Sep 17 00:00:00 2001 From: Tural Babayev Date: Thu, 29 Oct 2026 02:22:08 +0300 Subject: [PATCH 2/2] fix(policy): only cast create-input enums against stored columns Narrow the create-input text comparison from the previous commit: - A bare field counts as create input only at the rule's top level (`alias === thisAlias`). Inside collection predicates, including self-relations, it is a stored row and is left untouched. - The cast is applied only when the other operand is a stored field of the same enum. Comparisons with literals keep their current SQL, so the name mapper still maps `@map`-ed enum values. - Enums with `@map`-ed values are skipped: the input row holds enum names while stored columns hold mapped values, so comparing them as text could turn `!=` into an allow. Their behavior stays as on dev (#2867). Adds a self-relation regression case that was wrongly allowed before. --- .../policy/src/expression-transformer.ts | 64 +++++++++++++------ tests/regression/test/issue-2866.test.ts | 46 +++++++++++++ 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index fbde40015..a0f77d387 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -345,8 +345,9 @@ export class ExpressionTransformer { // enums read from a VALUES table (`before()` rows, create input) are text, and pg can't // compare text to a native enum const asText = - this.isValuesTableEnumField(normalizedLeft, context) || - this.isValuesTableEnumField(normalizedRight, context); + this.isBeforeEnumField(normalizedLeft, context) || + this.isBeforeEnumField(normalizedRight, context) || + this.isCreateInputEnumComparison(normalizedLeft, normalizedRight, context); const operand = (node: OperationNode) => asText ? this.dialect.castText(new ExpressionWrapper(node)) : new ExpressionWrapper(node); return this.dialect @@ -1216,27 +1217,54 @@ export class ExpressionTransformer { return !!fieldDef?.relation; } - private isValuesTableEnumField(expr: Expression, context: ExpressionTransformerContext) { - let field: string | undefined; - if (ExpressionUtils.isMember(expr) && isBeforeInvocation(expr.receiver)) { - // `before()` always refers to the model being updated (`thisType`), even inside - // collection predicates where `modelOrType` is the related model - field = expr.members[0]; - } else if (context.operation === 'create') { - // create rules read the new entity's own fields from the input VALUES table - if (ExpressionUtils.isMember(expr) && ExpressionUtils.isThis(expr.receiver) && expr.members.length === 1) { - field = expr.members[0]; - } else if (ExpressionUtils.isField(expr) && context.modelOrType === context.thisType) { - field = expr.field; - } - } - if (!field) { + private isBeforeEnumField(expr: Expression, context: ExpressionTransformerContext) { + if (!ExpressionUtils.isMember(expr) || !isBeforeInvocation(expr.receiver)) { return false; } - const fieldDef = QueryUtils.getField(this.schema, context.thisType, field); + // `before()` always refers to the model being updated (`thisType`), even inside + // collection predicates where `modelOrType` is the related model + const fieldDef = QueryUtils.getField(this.schema, context.thisType, expr.members[0]!); return !!fieldDef && QueryUtils.isEnum(this.schema, fieldDef.type); } + // create rules read the new entity's fields from the input VALUES table, where enums are text; + // compare as text only against a stored column of the same enum. `@map`-ed enums are skipped: + // the input holds names but stored columns hold mapped values (#2867) + private isCreateInputEnumComparison(left: Expression, right: Expression, context: ExpressionTransformerContext) { + if (context.operation !== 'create') { + return false; + } + const leftInput = this.getCreateInputEnumField(left, context); + const rightInput = this.getCreateInputEnumField(right, context); + if (!leftInput === !rightInput) { + return false; + } + const inputDef = (leftInput ?? rightInput)!; + const storedDef = this.getFieldDefFromFieldRef(leftInput ? right : left, context); + return storedDef?.type === inputDef.type && !this.hasMappedEnumValues(inputDef.type); + } + + private getCreateInputEnumField(expr: Expression, context: ExpressionTransformerContext) { + let field: string | undefined; + if (ExpressionUtils.isMember(expr) && ExpressionUtils.isThis(expr.receiver) && expr.members.length === 1) { + field = expr.members[0]; + } else if ( + ExpressionUtils.isField(expr) && + context.modelOrType === context.thisType && + // inside a collection predicate (incl. self-relations) a bare field is a stored row + context.alias === context.thisAlias + ) { + field = expr.field; + } + const fieldDef = field ? QueryUtils.getField(this.schema, context.thisType, field) : undefined; + return fieldDef && QueryUtils.isEnum(this.schema, fieldDef.type) ? fieldDef : undefined; + } + + private hasMappedEnumValues(enumType: string) { + const enumDef = QueryUtils.getEnum(this.schema, enumType); + return Object.values(enumDef?.fields ?? {}).some((f) => f.attributes?.some((attr) => attr.name === '@map')); + } + private getFieldDefFromFieldRef(expr: Expression, context: ExpressionTransformerContext): FieldDef | undefined { return this.resolveFieldRef(expr, context)?.fieldDef; } diff --git a/tests/regression/test/issue-2866.test.ts b/tests/regression/test/issue-2866.test.ts index ad6c02590..488e68219 100644 --- a/tests/regression/test/issue-2866.test.ts +++ b/tests/regression/test/issue-2866.test.ts @@ -99,4 +99,50 @@ model Grant { }); await expect(db.grant.create({ data: { id: 3, anchorId: 1, permission: 'EDIT' } })).toBeRejectedByPolicy(); }); + + it('treats self-relation rows as stored rows', async () => { + const db = await createPolicyTestClient( + ` +enum Status { + ACTIVE @map("active") + INACTIVE @map("inactive") +} + +enum Kind { + FILE + DIR +} + +model Folder { + id Int @id + status Status + kind Kind + parentId Int? + parent Folder? @relation("tree", fields: [parentId], references: [id]) + children Folder[] @relation("tree") + @@allow('read', true) + @@allow('create', parentId == null || parent.children?[status != ACTIVE] || parent.children?[kind == this.kind]) +} +`, + { provider: 'postgresql', usePrismaPush: true }, + ); + const raw = db.$unuseAll(); + await raw.folder.create({ data: { id: 1, status: 'INACTIVE', kind: 'DIR' } }); + await raw.folder.create({ data: { id: 2, status: 'ACTIVE', kind: 'DIR', parentId: 1 } }); + await raw.folder.create({ data: { id: 3, status: 'INACTIVE', kind: 'DIR' } }); + await raw.folder.create({ data: { id: 4, status: 'INACTIVE', kind: 'DIR', parentId: 3 } }); + + // the only child is ACTIVE and of another kind + await expect( + db.folder.create({ data: { id: 10, status: 'INACTIVE', kind: 'FILE', parentId: 1 } }), + ).toBeRejectedByPolicy(); + // a child that is not ACTIVE + await expect( + db.folder.create({ data: { id: 11, status: 'INACTIVE', kind: 'FILE', parentId: 3 } }), + ).resolves.toMatchObject({ id: 11 }); + // a child of the same kind + await expect( + db.folder.create({ data: { id: 12, status: 'INACTIVE', kind: 'DIR', parentId: 1 } }), + ).resolves.toMatchObject({ id: 12 }); + }); });