Skip to content

fix(policy): allow dangerous raw SQL opt-in#2502

Merged
ymc9 merged 5 commits intozenstackhq:devfrom
pkudinov:fix/policy-allow-raw-sql
Mar 20, 2026
Merged

fix(policy): allow dangerous raw SQL opt-in#2502
ymc9 merged 5 commits intozenstackhq:devfrom
pkudinov:fix/policy-allow-raw-sql

Conversation

@pkudinov
Copy link
Contributor

@pkudinov pkudinov commented Mar 20, 2026

Fixes #2495

Summary

  • add PolicyPluginOptions with a dangerouslyAllowRawSql escape hatch
  • bypass PolicyPlugin.onKyselyQuery for RawNode queries when that option is enabled
  • add a real policy-schema regression test showing that raw SQL still rejects by default, but succeeds inside the same transaction when the option is enabled

Why

$use() / $unuse() / $unuseAll() transaction preservation (#2497) fixes the transaction-affinity side of the problem, but it does not help with raw SQL if the policy plugin still intercepts the query and rejects non-CRUD nodes.

This change keeps the default behavior unchanged and adds an explicit opt-in for applications that need raw SQL to remain available inside authenticated transactions.

Testing

  • pnpm --filter @zenstackhq/plugin-policy build
  • TEST_PG_USER=user TEST_PG_PASSWORD=password pnpm --filter @zenstackhq/plugin-policy test -- test/raw-sql.test.ts

Summary by CodeRabbit

  • New Features

    • Policy plugin accepts configuration options, including a new dangerouslyAllowRawSql flag to change raw-SQL handling.
  • Tests

    • Added end-to-end tests verifying policy behavior when raw SQL is used inside transactions.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

Adds a new PolicyPluginOptions type with a dangerouslyAllowRawSql?: boolean flag, threads it through PolicyPluginPolicyHandler, and updates policy handling to optionally allow Kysely RawNode queries. Also adds an end-to-end test exercising raw-SQL behavior under the plugin.

Changes

Cohort / File(s) Summary
Policy Plugin
packages/plugins/policy/src/plugin.ts
Re-exported PolicyPluginOptions, added PolicyPlugin constructor parameter options: PolicyPluginOptions = {} and store; pass options to PolicyHandler.
Policy Handler
packages/plugins/policy/src/policy-handler.ts, packages/plugins/policy/src/options.ts
Introduced PolicyPluginOptions type (dangerouslyAllowRawSql?: boolean), updated PolicyHandler constructor to accept options, added RawNode handling in handle() to bypass enforcement when flag enabled.
Tests
tests/e2e/orm/policy/raw-sql.test.ts
New E2E test suite validating default rejection of raw SQL and permissive behavior when dangerouslyAllowRawSql: true, including provider-specific quoting helper and transaction-based cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble bits of SQL bold and sly,
A little flag lets raw queries fly.
Policies stay in their tidy lane,
While I hop free across the plain. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main change: introducing an opt-in mechanism to allow raw SQL in the policy plugin.
Linked Issues check ✅ Passed The PR implements the dangerouslyAllowRawSql opt-in option to allow raw SQL in PolicyPlugin, addressing the root cause described in #2495 and providing the requested workaround.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the dangerouslyAllowRawSql option: plugin options type definition, constructor updates, policy handler logic, and comprehensive test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/plugins/policy/test/raw-sql.test.ts (1)

35-65: Consider adding cleanup in afterAll.

The test setup creates database connections and test data but doesn't clean up afterward. This could lead to:

  • Connection pool exhaustion in CI when running multiple test suites
  • Test pollution if tests are run multiple times against the same database
♻️ Suggested cleanup pattern
+import { beforeAll, afterAll, describe, expect, it } from 'vitest';
 ...
+    afterAll(async () => {
+        // Clean up test data and close connections
+        await rawClient?.secret?.deleteMany({});
+        await rawClient?.user?.deleteMany({});
+        await defaultRawClient?.secret?.deleteMany({});
+        await defaultRawClient?.user?.deleteMany({});
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugins/policy/test/raw-sql.test.ts` around lines 35 - 65, Add an
afterAll teardown that cleans up connections and test data created in beforeAll:
call disconnect/teardown on unsafeClient and defaultClient (e.g.,
unsafeClient.$disconnect() and defaultClient.$disconnect()), and remove or reset
any test rows created via rawClient/defaultRawClient (e.g., delete the 'admin'
user) to avoid connection pool exhaustion and test pollution; reference
beforeAll setup symbols unsafeClient, rawClient, adminClient, defaultClient,
defaultRawClient, and defaultAdminClient when implementing the cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/plugins/policy/test/raw-sql.test.ts`:
- Around line 35-65: Add an afterAll teardown that cleans up connections and
test data created in beforeAll: call disconnect/teardown on unsafeClient and
defaultClient (e.g., unsafeClient.$disconnect() and
defaultClient.$disconnect()), and remove or reset any test rows created via
rawClient/defaultRawClient (e.g., delete the 'admin' user) to avoid connection
pool exhaustion and test pollution; reference beforeAll setup symbols
unsafeClient, rawClient, adminClient, defaultClient, defaultRawClient, and
defaultAdminClient when implementing the cleanup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c23f0116-fd99-4ee4-b947-3fedca5cc701

📥 Commits

Reviewing files that changed from the base of the PR and between 00768de and 41a503f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • packages/plugins/policy/package.json
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/test/raw-sql.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/e2e/orm/policy/raw-sql.test.ts (1)

39-57: Consider making dbName a required top-level parameter.

The type options?: { dangerouslyAllowRawSql?: boolean; dbName: string } is inconsistent: dbName is required within the object, but the entire object is optional. Both test calls provide dbName, suggesting it should always be required.

♻️ Proposed refactor for clearer function signature
-    async function createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean; dbName: string }) {
+    async function createPolicyClient(dbName: string, options?: { dangerouslyAllowRawSql?: boolean }) {
         const unsafeClient = await createTestClient(schema, {
-            dbName: options?.dbName,
+            dbName,
             plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: options?.dangerouslyAllowRawSql })],
         });

Then update the call sites:

-        const { adminClient } = await createPolicyClient({ dbName: 'policy_raw_sql_default' });
+        const { adminClient } = await createPolicyClient('policy_raw_sql_default');
-        const { adminClient } = await createPolicyClient({
-            dangerouslyAllowRawSql: true,
-            dbName: 'policy_raw_sql_dangerous',
-        });
+        const { adminClient } = await createPolicyClient('policy_raw_sql_dangerous', {
+            dangerouslyAllowRawSql: true,
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/orm/policy/raw-sql.test.ts` around lines 39 - 57, Change
createPolicyClient to require dbName as a top-level parameter instead of being
nested in an optional options object: update the signature from
createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean; dbName: string
}) to something like createPolicyClient(dbName: string, options?: {
dangerouslyAllowRawSql?: boolean }) and use the dbName param when calling
createTestClient and creating clients; then update all call sites to pass the
dbName string as the first argument (and keep dangerouslyAllowRawSql in the
optional second argument). Ensure references to options?.dbName in the function
body are replaced with the new dbName parameter and preserve existing behavior
for dangerouslyAllowRawSql via options.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/e2e/orm/policy/raw-sql.test.ts`:
- Around line 39-57: Change createPolicyClient to require dbName as a top-level
parameter instead of being nested in an optional options object: update the
signature from createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean;
dbName: string }) to something like createPolicyClient(dbName: string, options?:
{ dangerouslyAllowRawSql?: boolean }) and use the dbName param when calling
createTestClient and creating clients; then update all call sites to pass the
dbName string as the first argument (and keep dangerouslyAllowRawSql in the
optional second argument). Ensure references to options?.dbName in the function
body are replaced with the new dbName parameter and preserve existing behavior
for dangerouslyAllowRawSql via options.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ce9c492b-152c-41da-ad03-da62bd9568d0

📥 Commits

Reviewing files that changed from the base of the PR and between 41a503f and 48cc5a9.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • packages/plugins/policy/package.json
  • packages/plugins/policy/vitest.config.ts
  • tests/e2e/orm/policy/raw-sql.test.ts
💤 Files with no reviewable changes (1)
  • packages/plugins/policy/vitest.config.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/plugins/policy/package.json

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/plugins/policy/src/policy-handler.ts (1)

92-92: Use kind-based discrimination for RawNode guards instead of type casting.

The cast as never (or any cast) is unnecessary. Since RawNode is not part of the RootOperationNode union in Kysely, the idiomatic type-safe pattern is to check the node's kind property directly:

if (this.options.dangerouslyAllowRawSql && node.kind === 'RawNode') {
    return proceed(node);
}

This follows Kysely's discriminated union pattern used throughout the codebase (e.g., via SelectQueryNode.is(), InsertQueryNode.is()) and avoids type assertions altogether.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugins/policy/src/policy-handler.ts` at line 92, Replace the
unnecessary type cast in the condition that checks for raw SQL: inside the
PolicyHandler logic where it currently does "if
(this.options.dangerouslyAllowRawSql && RawNode.is(node as never))", use
kind-based discrimination instead—check this.options.dangerouslyAllowRawSql &&
node.kind === 'RawNode' (or use RawNode.is(node) without casting) and then
return proceed(node); update the guard around RawNode accordingly to remove the
"as never" cast and rely on the node.kind check so the code is type-safe and
follows the discriminated-union pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 26-27: The change introduced a raw SQL escape hatch via the
dangerouslyAllowRawSql flag that permits RawNode/ReferenceNode through policy
checks; revert that bypass by removing/ignoring dangerouslyAllowRawSql and
ensure RawNode and ReferenceNode are explicitly rejected in the policy
validation (the code that inspects node types where RawNode and ReferenceNode
are currently allowed). Instead of allowing raw nodes, update the policy-handler
to return a clear error or guidance directing callers to use the Kysely
query-builder escape hatch (e.g., add a message pointing to the query-builder
pathway) and add/reinforce tests around the functions that validate nodes so
non-CRUD raw nodes remain rejected. Ensure references to dangerouslyAllowRawSql,
RawNode, and ReferenceNode in the policy validation logic are changed
accordingly.

---

Nitpick comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Line 92: Replace the unnecessary type cast in the condition that checks for
raw SQL: inside the PolicyHandler logic where it currently does "if
(this.options.dangerouslyAllowRawSql && RawNode.is(node as never))", use
kind-based discrimination instead—check this.options.dangerouslyAllowRawSql &&
node.kind === 'RawNode' (or use RawNode.is(node) without casting) and then
return proceed(node); update the guard around RawNode accordingly to remove the
"as never" cast and rely on the node.kind check so the code is type-safe and
follows the discriminated-union pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ee2ef0b2-88a6-43fc-887a-a41e859c1f8e

📥 Commits

Reviewing files that changed from the base of the PR and between 48cc5a9 and cc4504b.

📒 Files selected for processing (2)
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/src/policy-handler.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/plugins/policy/src/plugin.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/plugins/policy/src/options.ts (1)

2-7: Document the preferred safe path alongside this escape hatch.

Please add an explicit note that Kysely query builder should be preferred, and this flag is only for last-resort raw SQL paths.

✏️ Suggested doc tweak
     /**
      * Dangerously bypasses access-policy enforcement for raw SQL queries.
+     * Prefer Kysely query builder APIs whenever possible; enable this only
+     * for unavoidable raw SQL in tightly controlled code paths.
      * Raw queries remain in the current transaction, but the policy plugin will
      * not inspect or reject them.
      */

As per coding guidelines, **/*.{ts,tsx}: Use Kysely query builder as escape hatch instead of raw SQL in ORM operations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/plugins/policy/src/options.ts` around lines 2 - 7, The doc comment
for the dangerouslyAllowRawSql option should be expanded to explicitly state
that this is a last-resort escape hatch and that the preferred safe path is to
use the Kysely query builder for ORM operations; update the comment on
dangerouslyAllowRawSql to recommend using Kysely (and follow the project's
coding guideline /**/*.{ts,tsx} to prefer Kysely over raw SQL), describe when
raw SQL may be acceptable, and emphasize that this flag only disables policy
checks for truly unavoidable cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/plugins/policy/src/options.ts`:
- Around line 2-7: The doc comment for the dangerouslyAllowRawSql option should
be expanded to explicitly state that this is a last-resort escape hatch and that
the preferred safe path is to use the Kysely query builder for ORM operations;
update the comment on dangerouslyAllowRawSql to recommend using Kysely (and
follow the project's coding guideline /**/*.{ts,tsx} to prefer Kysely over raw
SQL), describe when raw SQL may be acceptable, and emphasize that this flag only
disables policy checks for truly unavoidable cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fdc361f5-14b2-405a-a3a1-237691c097b6

📥 Commits

Reviewing files that changed from the base of the PR and between cc4504b and f3b8a6b.

📒 Files selected for processing (3)
  • packages/plugins/policy/src/options.ts
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/src/policy-handler.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/src/policy-handler.ts

Copy link
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@ymc9 ymc9 merged commit ece062f into zenstackhq:dev Mar 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PolicyPlugin rejects raw SQL methods ($queryRaw, $executeRaw)

2 participants