From 41a503fb90cb45af742ee6e0853133e2a7f398c2 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 08:29:27 -0700 Subject: [PATCH 1/5] fix(policy): allow raw sql opt-in --- packages/plugins/policy/package.json | 2 + packages/plugins/policy/src/plugin.ts | 15 +++ packages/plugins/policy/test/raw-sql.test.ts | 106 +++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 126 insertions(+) create mode 100644 packages/plugins/policy/test/raw-sql.test.ts diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 712aa1a9f..d6f591134 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -7,6 +7,7 @@ "build": "tsc --noEmit && tsup-node", "watch": "tsup-node --watch", "lint": "eslint src --ext ts", + "test": "vitest run", "pack": "pnpm pack" }, "keywords": [], @@ -48,6 +49,7 @@ "@types/better-sqlite3": "catalog:", "@types/pg": "^8.0.0", "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/testtools": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*" } diff --git a/packages/plugins/policy/src/plugin.ts b/packages/plugins/policy/src/plugin.ts index d5cf192c4..f03694d5e 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -1,9 +1,21 @@ import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; +import { RawNode } from 'kysely'; import { check } from './functions'; import { PolicyHandler } from './policy-handler'; +export type PolicyPluginOptions = { + /** + * Dangerously bypasses access-policy enforcement for raw SQL queries. + * Raw queries remain in the current transaction, but the policy plugin will + * not inspect or reject them. + */ + dangerouslyAllowRawSql?: boolean; +}; + export class PolicyPlugin implements RuntimePlugin { + constructor(private readonly options: PolicyPluginOptions = {}) {} + get id() { return 'policy' as const; } @@ -23,6 +35,9 @@ export class PolicyPlugin implements RuntimePlugin { } onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs) { + if (this.options.dangerouslyAllowRawSql && RawNode.is(query as never)) { + return proceed(query); + } const handler = new PolicyHandler(client); return handler.handle(query, proceed); } diff --git a/packages/plugins/policy/test/raw-sql.test.ts b/packages/plugins/policy/test/raw-sql.test.ts new file mode 100644 index 000000000..d66136714 --- /dev/null +++ b/packages/plugins/policy/test/raw-sql.test.ts @@ -0,0 +1,106 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import type { SchemaDef } from '@zenstackhq/orm/schema'; +import { createTestClient } from '@zenstackhq/testtools'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { PolicyPlugin } from '../src/plugin'; + +const schema = ` +model User { + id String @id + role String + secrets Secret[] + + @@allow('all', true) +} + +model Secret { + id String @id + value String + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + @@allow('read', auth() != null && auth().role == 'admin') + @@allow('create', auth() != null && auth().role == 'admin') +} +`; + +describe('PolicyPlugin raw SQL', () => { + let unsafeClient: ClientContract; + let rawClient: ClientContract; + let adminClient: ClientContract; + let defaultClient: ClientContract; + let defaultRawClient: ClientContract; + let defaultAdminClient: ClientContract; + + beforeAll(async () => { + unsafeClient = await createTestClient(schema, { + plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: true })], + provider: 'postgresql', + dbName: 'policy_raw_sql_dangerous', + }); + rawClient = unsafeClient.$unuseAll(); + adminClient = unsafeClient.$setAuth({ id: 'admin', role: 'admin' }); + + await rawClient.user.create({ + data: { + id: 'admin', + role: 'admin', + }, + }); + + defaultClient = await createTestClient(schema, { + plugins: [new PolicyPlugin()], + provider: 'postgresql', + dbName: 'policy_raw_sql_default', + }); + defaultRawClient = defaultClient.$unuseAll(); + defaultAdminClient = defaultClient.$setAuth({ id: 'admin', role: 'admin' }); + + await defaultRawClient.user.create({ + data: { + id: 'admin', + role: 'admin', + }, + }); + }); + + it('keeps rejecting raw SQL by default', async () => { + await expect( + defaultAdminClient.$transaction(async (tx) => { + await tx.secret.create({ + data: { + id: 'secret-default', + ownerId: 'admin', + value: 'still-guarded', + }, + }); + + await tx.$queryRaw<{ value: string }[]>` + SELECT "value" + FROM "Secret" + WHERE "id" = ${'secret-default'} + `; + }), + ).rejects.toThrow('non-CRUD queries are not allowed'); + }); + + it('allows raw SQL inside a transaction when dangerous raw SQL is enabled', async () => { + await adminClient.$transaction(async (tx) => { + await tx.secret.create({ + data: { + id: 'secret-1', + ownerId: 'admin', + value: 'top-secret', + }, + }); + + const rows = await tx.$queryRaw<{ value: string }[]>` + SELECT "value" + FROM "Secret" + WHERE "id" = ${'secret-1'} + `; + + expect(rows).toEqual([{ value: 'top-secret' }]); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5ac83d9e..17502a4c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,9 @@ importers: '@zenstackhq/eslint-config': specifier: workspace:* version: link:../../config/eslint-config + '@zenstackhq/testtools': + specifier: workspace:* + version: link:../../testtools '@zenstackhq/typescript-config': specifier: workspace:* version: link:../../config/typescript-config From 48cc5a90377fabd5c6d78f20e6d6ba875015c203 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 09:00:04 -0700 Subject: [PATCH 2/5] test(policy): move raw sql regression to e2e --- packages/plugins/policy/package.json | 6 +- packages/plugins/policy/vitest.config.ts | 4 - pnpm-lock.yaml | 9 --- .../e2e/orm/policy}/raw-sql.test.ts | 77 +++++++++---------- 4 files changed, 39 insertions(+), 57 deletions(-) delete mode 100644 packages/plugins/policy/vitest.config.ts rename {packages/plugins/policy/test => tests/e2e/orm/policy}/raw-sql.test.ts (53%) diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index d6f591134..8f97a78de 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -7,7 +7,6 @@ "build": "tsc --noEmit && tsup-node", "watch": "tsup-node --watch", "lint": "eslint src --ext ts", - "test": "vitest run", "pack": "pnpm pack" }, "keywords": [], @@ -47,10 +46,7 @@ }, "devDependencies": { "@types/better-sqlite3": "catalog:", - "@types/pg": "^8.0.0", "@zenstackhq/eslint-config": "workspace:*", - "@zenstackhq/testtools": "workspace:*", - "@zenstackhq/typescript-config": "workspace:*", - "@zenstackhq/vitest-config": "workspace:*" + "@zenstackhq/typescript-config": "workspace:*" } } diff --git a/packages/plugins/policy/vitest.config.ts b/packages/plugins/policy/vitest.config.ts deleted file mode 100644 index 75a9f709c..000000000 --- a/packages/plugins/policy/vitest.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import base from '@zenstackhq/vitest-config/base'; -import { defineConfig, mergeConfig } from 'vitest/config'; - -export default mergeConfig(base, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17502a4c6..394a7b001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,21 +631,12 @@ importers: '@types/better-sqlite3': specifier: 'catalog:' version: 7.6.13 - '@types/pg': - specifier: ^8.0.0 - version: 8.11.11 '@zenstackhq/eslint-config': specifier: workspace:* version: link:../../config/eslint-config - '@zenstackhq/testtools': - specifier: workspace:* - version: link:../../testtools '@zenstackhq/typescript-config': specifier: workspace:* version: link:../../config/typescript-config - '@zenstackhq/vitest-config': - specifier: workspace:* - version: link:../../config/vitest-config packages/schema: dependencies: diff --git a/packages/plugins/policy/test/raw-sql.test.ts b/tests/e2e/orm/policy/raw-sql.test.ts similarity index 53% rename from packages/plugins/policy/test/raw-sql.test.ts rename to tests/e2e/orm/policy/raw-sql.test.ts index d66136714..769cc3e57 100644 --- a/packages/plugins/policy/test/raw-sql.test.ts +++ b/tests/e2e/orm/policy/raw-sql.test.ts @@ -1,8 +1,9 @@ +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; import type { ClientContract } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; import { createTestClient } from '@zenstackhq/testtools'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { PolicyPlugin } from '../src/plugin'; +import { sql } from 'kysely'; +import { afterEach, describe, expect, it } from 'vitest'; const schema = ` model User { @@ -24,49 +25,42 @@ model Secret { } `; -describe('PolicyPlugin raw SQL', () => { - let unsafeClient: ClientContract; - let rawClient: ClientContract; - let adminClient: ClientContract; - let defaultClient: ClientContract; - let defaultRawClient: ClientContract; - let defaultAdminClient: ClientContract; - - beforeAll(async () => { - unsafeClient = await createTestClient(schema, { - plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: true })], - provider: 'postgresql', - dbName: 'policy_raw_sql_dangerous', - }); - rawClient = unsafeClient.$unuseAll(); - adminClient = unsafeClient.$setAuth({ id: 'admin', role: 'admin' }); +describe('Policy raw SQL tests', () => { + const clients: ClientContract[] = []; - await rawClient.user.create({ - data: { - id: 'admin', - role: 'admin', - }, - }); + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.$disconnect())); + }); - defaultClient = await createTestClient(schema, { - plugins: [new PolicyPlugin()], - provider: 'postgresql', - dbName: 'policy_raw_sql_default', + function ref(client: ClientContract, col: string) { + return client.$schema.provider.type === 'mysql' ? sql.raw(`\`${col}\``) : sql.raw(`"${col}"`); + } + + async function createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean; dbName: string }) { + const unsafeClient = await createTestClient(schema, { + dbName: options?.dbName, + plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: options?.dangerouslyAllowRawSql })], }); - defaultRawClient = defaultClient.$unuseAll(); - defaultAdminClient = defaultClient.$setAuth({ id: 'admin', role: 'admin' }); + clients.push(unsafeClient); + + const rawClient = unsafeClient.$unuseAll(); + const adminClient = unsafeClient.$setAuth({ id: 'admin', role: 'admin' }); - await defaultRawClient.user.create({ + await rawClient.user.create({ data: { id: 'admin', role: 'admin', }, }); - }); + + return { adminClient }; + } it('keeps rejecting raw SQL by default', async () => { + const { adminClient } = await createPolicyClient({ dbName: 'policy_raw_sql_default' }); + await expect( - defaultAdminClient.$transaction(async (tx) => { + adminClient.$transaction(async (tx) => { await tx.secret.create({ data: { id: 'secret-default', @@ -76,15 +70,20 @@ describe('PolicyPlugin raw SQL', () => { }); await tx.$queryRaw<{ value: string }[]>` - SELECT "value" - FROM "Secret" - WHERE "id" = ${'secret-default'} + SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')} + FROM ${ref(tx, 'Secret')} + WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-default'} `; }), ).rejects.toThrow('non-CRUD queries are not allowed'); }); it('allows raw SQL inside a transaction when dangerous raw SQL is enabled', async () => { + const { adminClient } = await createPolicyClient({ + dangerouslyAllowRawSql: true, + dbName: 'policy_raw_sql_dangerous', + }); + await adminClient.$transaction(async (tx) => { await tx.secret.create({ data: { @@ -95,9 +94,9 @@ describe('PolicyPlugin raw SQL', () => { }); const rows = await tx.$queryRaw<{ value: string }[]>` - SELECT "value" - FROM "Secret" - WHERE "id" = ${'secret-1'} + SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')} + FROM ${ref(tx, 'Secret')} + WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-1'} `; expect(rows).toEqual([{ value: 'top-secret' }]); From cd78922d18c133daef842975b1195db4d6aa264d Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 09:04:46 -0700 Subject: [PATCH 3/5] chore: restore untouched policy package files --- packages/plugins/policy/package.json | 4 +++- packages/plugins/policy/vitest.config.ts | 4 ++++ pnpm-lock.yaml | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/policy/vitest.config.ts diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 8f97a78de..712aa1a9f 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -46,7 +46,9 @@ }, "devDependencies": { "@types/better-sqlite3": "catalog:", + "@types/pg": "^8.0.0", "@zenstackhq/eslint-config": "workspace:*", - "@zenstackhq/typescript-config": "workspace:*" + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*" } } diff --git a/packages/plugins/policy/vitest.config.ts b/packages/plugins/policy/vitest.config.ts new file mode 100644 index 000000000..75a9f709c --- /dev/null +++ b/packages/plugins/policy/vitest.config.ts @@ -0,0 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(base, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 394a7b001..c5ac83d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,12 +631,18 @@ importers: '@types/better-sqlite3': specifier: 'catalog:' version: 7.6.13 + '@types/pg': + specifier: ^8.0.0 + version: 8.11.11 '@zenstackhq/eslint-config': specifier: workspace:* version: link:../../config/eslint-config '@zenstackhq/typescript-config': specifier: workspace:* version: link:../../config/typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config packages/schema: dependencies: From cc4504b2b23cfb8c9d5eb9a4de3f76dc6b2c797e Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 11:02:03 -0700 Subject: [PATCH 4/5] refactor(policy): move raw sql allowance into handler --- packages/plugins/policy/src/plugin.ts | 17 +++-------------- packages/plugins/policy/src/policy-handler.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/plugins/policy/src/plugin.ts b/packages/plugins/policy/src/plugin.ts index f03694d5e..f5896d1db 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -1,17 +1,9 @@ import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; -import { RawNode } from 'kysely'; import { check } from './functions'; -import { PolicyHandler } from './policy-handler'; +import { PolicyHandler, type PolicyHandlerOptions } from './policy-handler'; -export type PolicyPluginOptions = { - /** - * Dangerously bypasses access-policy enforcement for raw SQL queries. - * Raw queries remain in the current transaction, but the policy plugin will - * not inspect or reject them. - */ - dangerouslyAllowRawSql?: boolean; -}; +export type PolicyPluginOptions = PolicyHandlerOptions; export class PolicyPlugin implements RuntimePlugin { constructor(private readonly options: PolicyPluginOptions = {}) {} @@ -35,10 +27,7 @@ export class PolicyPlugin implements RuntimePlugin { } onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs) { - if (this.options.dangerouslyAllowRawSql && RawNode.is(query as never)) { - return proceed(query); - } - const handler = new PolicyHandler(client); + const handler = new PolicyHandler(client, this.options); return handler.handle(query, proceed); } } diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index d774ac007..4ad7bfecb 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -23,6 +23,7 @@ import { OperatorNode, ParensNode, PrimitiveValueListNode, + RawNode, ReferenceNode, ReturningNode, SelectAllNode, @@ -63,11 +64,23 @@ export type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryN type FieldLevelPolicyOperations = Exclude; +export type PolicyHandlerOptions = { + /** + * Dangerously bypasses access-policy enforcement for raw SQL queries. + * Raw queries remain in the current transaction, but the policy plugin will + * not inspect or reject them. + */ + dangerouslyAllowRawSql?: boolean; +}; + export class PolicyHandler extends OperationNodeTransformer { private readonly dialect: BaseCrudDialect; private readonly eb = expressionBuilder(); - constructor(private readonly client: ClientContract) { + constructor( + private readonly client: ClientContract, + private readonly options: PolicyHandlerOptions = {}, + ) { super(); this.dialect = getCrudDialect(this.client.$schema, this.client.$options); } @@ -76,6 +89,9 @@ export class PolicyHandler extends OperationNodeTransf async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) { if (!this.isCrudQueryNode(node)) { + if (this.options.dangerouslyAllowRawSql && RawNode.is(node as never)) { + return proceed(node); + } // non-CRUD queries are not allowed throw createRejectedByPolicyError( undefined, From f3b8a6b8e829eb93128abfae76e7da3634a44fcf Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 20 Mar 2026 11:04:59 -0700 Subject: [PATCH 5/5] refactor(policy): extract plugin options type --- packages/plugins/policy/src/options.ts | 8 ++++++++ packages/plugins/policy/src/plugin.ts | 5 +++-- packages/plugins/policy/src/policy-handler.ts | 12 ++---------- 3 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/policy/src/options.ts diff --git a/packages/plugins/policy/src/options.ts b/packages/plugins/policy/src/options.ts new file mode 100644 index 000000000..7858b4e72 --- /dev/null +++ b/packages/plugins/policy/src/options.ts @@ -0,0 +1,8 @@ +export type PolicyPluginOptions = { + /** + * Dangerously bypasses access-policy enforcement for raw SQL queries. + * Raw queries remain in the current transaction, but the policy plugin will + * not inspect or reject them. + */ + dangerouslyAllowRawSql?: boolean; +}; diff --git a/packages/plugins/policy/src/plugin.ts b/packages/plugins/policy/src/plugin.ts index f5896d1db..61bf7b73a 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -1,9 +1,10 @@ import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; +import type { PolicyPluginOptions } from './options'; import { check } from './functions'; -import { PolicyHandler, type PolicyHandlerOptions } from './policy-handler'; +import { PolicyHandler } from './policy-handler'; -export type PolicyPluginOptions = PolicyHandlerOptions; +export type { PolicyPluginOptions } from './options'; export class PolicyPlugin implements RuntimePlugin { constructor(private readonly options: PolicyPluginOptions = {}) {} diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 4ad7bfecb..ba634d89d 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -43,6 +43,7 @@ import { import { match } from 'ts-pattern'; import { ColumnCollector } from './column-collector'; import { ExpressionTransformer } from './expression-transformer'; +import type { PolicyPluginOptions } from './options'; import type { Policy, PolicyOperation } from './types'; import { buildIsFalse, @@ -64,22 +65,13 @@ export type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryN type FieldLevelPolicyOperations = Exclude; -export type PolicyHandlerOptions = { - /** - * Dangerously bypasses access-policy enforcement for raw SQL queries. - * Raw queries remain in the current transaction, but the policy plugin will - * not inspect or reject them. - */ - dangerouslyAllowRawSql?: boolean; -}; - export class PolicyHandler extends OperationNodeTransformer { private readonly dialect: BaseCrudDialect; private readonly eb = expressionBuilder(); constructor( private readonly client: ClientContract, - private readonly options: PolicyHandlerOptions = {}, + private readonly options: PolicyPluginOptions = {}, ) { super(); this.dialect = getCrudDialect(this.client.$schema, this.client.$options);