From cb8e00acd25347169e1e5e0b58222e788c56aba1 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 16 Sep 2026 18:37:06 +0100 Subject: [PATCH] fix(policy): skip per-row create checks when the filter doesn't reference the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createMany` on a policy-protected model checked each row with its own `select exists (...)` round trip, awaited in sequence. When the create filter is built only from `auth()` it carries no reference to the row, so it compiles to a constant and every check re-asks the same question — a 32-row batch cost 32 serial queries evaluating `where true`. `preCreateCheck` already short-circuits on a constant policy, but via `tryGetConstantPolicy`, which only matches a literal `true` in the ZModel. This tests the built filter with `isTrueNode` instead, the same way `preUpdateCheck` already does. Row-dependent filters are unaffected and still checked per row. Fixes #2841 --- packages/plugins/policy/src/policy-handler.ts | 10 ++ tests/regression/test/issue-2841.test.ts | 92 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/regression/test/issue-2841.test.ts diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index b84c39cd4..a1be28e41 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -921,6 +921,16 @@ export class PolicyHandler extends OperationNodeTransf const valueRows = node.values ? this.unwrapCreateValueRows(node.values, mutationModel, fields, isManyToManyJoinTable) : [[]]; + + if (!isManyToManyJoinTable) { + // A create filter built only from `auth()` doesn't reference the row being inserted, + // so it holds for every row alike and checking it per row re-asks the same question. + const filter = this.buildPolicyFilter(mutationModel, undefined, 'create'); + if (isTrueNode(filter)) { + return; + } + } + for (const values of valueRows) { if (isManyToManyJoinTable) { await this.enforcePreCreatePolicyForManyToManyJoinTable( diff --git a/tests/regression/test/issue-2841.test.ts b/tests/regression/test/issue-2841.test.ts new file mode 100644 index 000000000..b5e496df1 --- /dev/null +++ b/tests/regression/test/issue-2841.test.ts @@ -0,0 +1,92 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import type { LogEvent } from 'kysely'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2841 +describe('Regression for issue #2841', () => { + const schema = ` +type Auth { + id String + isSystem Boolean + + @@auth +} + +model AuditLog { + id String @id @default(cuid()) + action String + + @@allow('create', auth().isSystem) + @@allow('read', true) +} + +model Note { + id String @id @default(cuid()) + ownerId String + + @@allow('create', ownerId == auth().id) + @@allow('read', true) +} +`; + + async function createClient() { + const sqls: string[] = []; + const db = await createPolicyTestClient(schema, { + log: (event: LogEvent) => { + if (event.level === 'query') { + sqls.push(event.query.sql); + } + }, + }); + // the pre-create policy check is the only query selecting a `$condition` alias + return { db, policyChecks: () => sqls.filter((sql) => sql.includes('$condition')) }; + } + + it('does not run a per-row policy check when the create filter does not reference the row', async () => { + const { db, policyChecks } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: true }); + + await expect( + authDb.auditLog.createMany({ + data: Array.from({ length: 5 }, (_, i) => ({ action: `action-${i}` })), + }), + ).resolves.toMatchObject({ count: 5 }); + + // `auth().isSystem` resolves to a constant, so one answer covers the whole batch + expect(policyChecks()).toHaveLength(0); + await expect(db.auditLog.findMany()).resolves.toHaveLength(5); + }); + + it('still rejects the batch when a non-row-dependent create filter denies', async () => { + const { db } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: false }); + + await expect( + authDb.auditLog.createMany({ + data: [{ action: 'action-0' }], + }), + ).rejects.toThrow(/rejected by access policies/i); + + await expect(db.auditLog.findMany()).resolves.toHaveLength(0); + }); + + it('still checks each row when the create filter references the row', async () => { + const { db, policyChecks } = await createClient(); + const authDb = db.$setAuth({ id: 'u1', isSystem: true }); + + await expect( + authDb.note.createMany({ + data: [{ ownerId: 'u1' }, { ownerId: 'u1' }], + }), + ).resolves.toMatchObject({ count: 2 }); + expect(policyChecks()).toHaveLength(2); + + // a single offending row still rejects the whole batch + await expect( + authDb.note.createMany({ + data: [{ ownerId: 'u1' }, { ownerId: 'someone-else' }], + }), + ).rejects.toThrow(/rejected by access policies/i); + await expect(db.note.findMany()).resolves.toHaveLength(2); + }); +});