Skip to content

feat(policy): add optional error code to @@allow/@@deny attributes#2636

Open
Azzerty23 wants to merge 23 commits intozenstackhq:devfrom
Azzerty23:feat/policy-custom-error-codes
Open

feat(policy): add optional error code to @@allow/@@deny attributes#2636
Azzerty23 wants to merge 23 commits intozenstackhq:devfrom
Azzerty23:feat/policy-custom-error-codes

Conversation

@Azzerty23
Copy link
Copy Markdown
Contributor

@Azzerty23 Azzerty23 commented May 1, 2026

What this PR does

Before this PR, policy rejections surfaced either as a generic error with no machine-readable detail, or — for update, delete, and throwing single-row reads (findFirstOrThrow, findUniqueOrThrow) — as a plain NOT_FOUND, making it impossible to distinguish a missing row from a denied one. There was no structured information to branch on, even by parsing the error message.

This PR adds an optional errorCode argument to @@allow and @@deny policy attributes. When an operation is rejected, every matching rule's code is collected and surfaced on ORMError.policyCodes, giving callers a reliable way to handle specific policy violations programmatically.

Fixes #1723


Schema syntax

The third argument accepts a string literal or an enum value:

enum OrderError {
  OUT_OF_STOCK
  CREDIT_EXCEEDED
  INVALID_STATUS_TRANSITION
}

model Order {
  ...
  @@deny('create',        total > auth().creditLimit,                            CREDIT_EXCEEDED)
  @@deny('post-update',   stock < 0,                                             OUT_OF_STOCK)
  @@deny('post-update',   before().status == 'shipped' && status != 'delivered', INVALID_STATUS_TRANSITION)
  @@deny('create,update', auth().accountStatus == 'suspended',                   'ACCOUNT_SUSPENDED')
  @@deny('read',          archived == true,                                      'ARCHIVED_ORDER')
  @@deny('delete',        auth().role != 'admin',                                'ADMIN_ONLY')
}

Error surfacing

ORMError.policyCodes contains the codes of all matching rules (not just the first), so a single failed operation can carry multiple codes. Ex:

import { ORMError, ORMErrorReason } from '@zenstackhq/orm';
import { OrderError } from './zenstack/models';

const POLICY_CODE_RESPONSES: Record<OrderError, { status: number; error: string }> = {
  [OrderError.CREDIT_EXCEEDED]:   { status: 402, error: 'Payment limit reached' },
  [OrderError.ACCOUNT_SUSPENDED]: { status: 403, error: 'Account suspended' },
  [OrderError.OUT_OF_STOCK]:      { status: 409, error: 'Item out of stock' },
};

try {
  await db.order.create({ data: { ... } });
} catch (err) {
  if (err instanceof ORMError && err.reason === ORMErrorReason.REJECTED_BY_POLICY) {
    const matched = err.policyCodes
      ?.map((code) => POLICY_CODE_RESPONSES[code as OrderError])
      .find(Boolean);

    const { status, error } = matched ?? { status: 403, error: 'Operation not allowed' };
    return res.json({ status, error });
  }
}

Scope: which operations surface codes

Operation Without error code With error code
create / post-update REJECTED_BY_POLICY (no codes) REJECTED_BY_POLICY + policyCodes
update / delete / findFirstOrThrow / findUniqueOrThrow NOT_FOUND REJECTED_BY_POLICY + policyCodes
findFirst / findUnique returns null returns null (never throws)
findMany returns [] returns [] (never throws)

create and post-update already run a dedicated SELECT as part of normal enforcement (SELECT EXISTS(...) before the INSERT, post-write SELECT for post-update). The rejection is always REJECTED_BY_POLICY; code collection is piggybacked on that existing query — one extra EXISTS column per custom error code, zero additional round-trips.

update, delete, and findFirstOrThrow / findUniqueOrThrow use filter-based enforcement (WHERE clause injection). When 0 rows come back, the ORM can't distinguish "row doesn't exist" from "row was denied by policy". To surface codes, a separate diagnostic query is required: first check whether a matching row exists without the policy filter, then collect the codes of the rules that fired. This extra round-trip only runs when at least one rule on the model carries a code — so the opt-in is implicit via the schema: adding a code to any rule automatically upgrades the default behavior to REJECTED_BY_POLICY (with policyCodes set) for that model, at the cost of one additional query for the rejected operation.

findFirst and findUnique always return null on policy denial regardless — they are filter-based and never throw, so no diagnostic query is ever triggered for them.

findMany is never affected — it uses filter-based enforcement and silently excludes denied rows.


Performance opt-out

The codes collection step can be disabled via fetchPolicyCodes: false to fall back to the default behavior.

// plugin-level: off for all queries on this client
new PolicyPlugin({ fetchPolicyCodes: false })

// query-level: off (or back on) for a single call
await db.order.update({ where: { id }, data: { ... }, fetchPolicyCodes: false });
await db.order.update({ where: { id }, data: { ... }, fetchPolicyCodes: true });  // overrides plugin-level false

Backward compatibility

Purely additive. No changes to the existing error message format or ORMError structure. Callers that don't use @@allow/@@deny error codes see no behavioral change. policyCodes is undefined (not []) when no codes are present.

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

Summary by CodeRabbit

  • New Features

    • Policy attributes may include an optional error code (string or enum) surfaced for create and post-update rejections.
    • Rejection errors can include a list of violated policy codes.
    • Per-plugin and per-query option to control retrieval of policy codes.
  • Bug Fixes

    • Query-builder update/delete targeting nonexistent rows no longer throw; “not found” vs “denied by policy” is distinguished.
  • Tests

    • Comprehensive E2E suite validating policy code propagation, aggregation, enum/string codes, and fetch-option behavior.
    • Test matcher updated to assert expected policy codes.
  • Chores

    • Added runtime validation dependency and TypeScript node typings; compiler config updated.

Adds an optional third `errorCode` string argument to @@Allow, @@deny,
@Allow, and @deny policy attributes. When a create or post-update
operation is rejected, the matching rule's code is surfaced on
ORMError.policyCode so callers can handle specific policy violations
without parsing error messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional per-policy error codes: extends policy DSL to accept an errorCode argument, validates its usage, threads a per-query fetchPolicyCodes flag via AsyncLocalStorage, runs optional diagnostic queries to collect violated policy codes, and surfaces them on thrown ORMError.policyCodes.

Changes

Policy error codes + diagnostics

Layer / File(s) Summary
DSL / Attribute
packages/plugins/policy/plugin.zmodel
@@allow/@@deny signatures accept an optional 3rd _ errorCode: Any? parameter documented as an optional error code attached to rejections.
Validation
packages/language/src/validators/attribute-application-validator.ts
Adds validateCustomErrorCode used by model-level policy attribute validation: accepts enum field references as-is, requires non-empty string literals, rejects non-literals or empty/whitespace-only strings.
Policy type
packages/plugins/policy/src/types.ts
Policy now optionally carries code?: string.
Policy parsing
packages/plugins/policy/src/policy-handler.ts
Model- and field-level policy parsing extended to read a 3rd argument as code only when it is a string literal.
Core handler: diagnostics & checks
packages/plugins/policy/src/policy-handler.ts
Adds diagnostic flows: collects literal codes for unconditional denies, runs single SELECT diagnostics (one EXISTS column per coded policy, with allow rules negated) to determine which coded policies fired for create/post-update/update/delete when fetchPolicyCodes !== false; distinguishes “not found” vs “denied” for zero-rows affected and throws errors with discovered codes. Adds helpers findViolatingCreatePolicyCodes, findViolatingPostUpdatePolicyCodes, and evaluatePolicyDiagnostics.
Error creation
packages/plugins/policy/src/utils.ts
createRejectedByPolicyError gains an optional policyCodes?: string[] parameter and sets err.policyCodes when non-empty.

Runtime plumbing & options

Layer / File(s) Summary
Options shape
packages/plugins/policy/src/options.ts
Adds fetchPolicyCodes?: boolean to PolicyPluginOptions.
Per-query args & context
packages/plugins/policy/src/plugin.ts
Introduces PolicyExtQueryArgs, a Zod fetchPolicyCodes schema, AsyncLocalStorage-backed PolicyQueryContext, onQuery to store per-query fetchPolicyCodes, and onKyselyQuery changes to merge per-query flag into effectiveOptions when constructing PolicyHandler. Updates RuntimePlugin generics to include ext args.
Compiler typings
packages/plugins/policy/tsconfig.json
Adds Node typings to compiler types.
Package deps
packages/plugins/policy/package.json
Adds zod dependency and @types/node devDependency.

Error surface & test support

Layer / File(s) Summary
Error shape
packages/orm/src/client/errors.ts
ORMError gains optional policyCodes?: string[] with JSDoc describing it is set for REJECTED_BY_POLICY (create/post-update/update/delete) when matching rules provided codes.
Test helpers
packages/testtools/src/types.d.ts, packages/testtools/src/vitest-ext.ts
toBeRejectedByPolicy matcher signature extended to accept expectedCodes?: string[]; matcher logic now validates err.policyCodes (computes missing/extra codes) when expected codes provided.
E2E tests
tests/e2e/orm/policy/crud/error-codes.test.ts
Comprehensive tests added to assert code propagation (string literals, enum references, mixed), multi-rule aggregation, batch behavior, auth()/before() scenarios, and plugin-level vs query-level fetchPolicyCodes permutations (false/true/default).
Query-builder edge tests
tests/e2e/orm/policy/crud/delete.test.ts, tests/e2e/orm/policy/crud/update.test.ts
Added tests ensuring query-builder update/delete targeting nonexistent rows do not throw and return zero affected rows.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nibbled through the policy maze tonight,
Found tiny codes that twinkled in the byte,
When creates are blocked and updates stall,
They now tell which rule tripped the fall,
Hoppity cheers — diagnostics gleam so bright.

🚥 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
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: adding an optional error code parameter to policy attributes.
Linked Issues check ✅ Passed The implementation fully addresses issue #1723 by adding an optional third parameter to policy attributes for custom error codes, surfacing them on ORMError, and enabling machine-handleable policy violation differentiation.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the error code feature for policies. TypeScript configuration, test tooling updates, and diagnostic query logic all support the core objective.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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.

Azzerty23 and others added 3 commits May 1, 2026 16:11
…t-match only

Changes `ORMError.policyCode` to `policyCodes: string[]` so every rule
that contributed to a rejection (deny rules that fired, allow rules that
failed) is reported. The diagnostic query path is also collapsed from N
sequential per-rule round-trips into a single batched query with one
EXISTS column per coded policy, which is both faster and correct for the
multi-code case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `fetchPolicyCodes` flag to `PolicyPluginOptions` (plugin-level default)
and as a per-query arg on `$create`/`$update`, using AsyncLocalStorage to
bridge the flag into the Kysely executor. Set to `false` to skip the extra
diagnostic query when error codes are not needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Azzerty23
Copy link
Copy Markdown
Contributor Author

Azzerty23 commented May 1, 2026

Ok, after thinking more, I think I found a respectable balance : like you said @ymc9 in the original issue, when a policy check fails, a second diagnostic query collects the error codes of all violated policies. Could be done in one round-trip with CTE-based approach but add complexity for all queries (even succeed ones).
In the last commit, I added an opt-out option (plugin-level and query-level). It's based on AsyncLocalStorage, I wonder if it is well supported by edge runtimes. Without AsyncLocalStorage, the code was turning into a mess...

EDIT: for better compatibility I replaced AsyncLocalStorage with a per-operation queryContext map, explicitly threaded into the Kysely executor so plugins can pass arbitrary data from onQuery to onKyselyQuery.

@Azzerty23 Azzerty23 marked this pull request as ready for review May 1, 2026 19:45
Copy link
Copy Markdown
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: 5

🤖 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/language/src/validators/attribute-application-validator.ts`:
- Around line 284-286: The validator currently accepts an errorCode argument for
field-level `@allow/`@deny silently; update _checkFieldLevelPolicy to detect when
attr.args[2] (passed into validateCustomErrorCode) is present for field-level
policies and emit a warning instead of silently accepting it: inside
_checkFieldLevelPolicy (the function validating operations
['read','update','all']) check if attr.args[2] exists and, since field-level
policies cannot surface error codes for non-create/post-update operations, call
the existing warning/logging path (or add one) to notify the user that errorCode
on field-level policies is ignored; keep calling validateCustomErrorCode to
preserve format checks but ensure a warning is emitted at this call site when
attr.args[2] is provided for field-level `@allow/`@deny.
- Around line 288-308: The policy-handler currently only extracts string error
codes via ExpressionUtils.isLiteral(... && typeof ... === 'string') which
ignores enum references; update the extraction in policy-handler.ts to also
accept ReferenceExpr enum references by checking if the expression is a
ReferenceExpr and isEnumFieldReference(referenceExpr) is true, and then derive
the code string from that reference (use the ReferenceExpr's name/target
components to build the same code identifier the validator expects) so the
downstream .filter((p) => p.code) will see a non-undefined code for enum values;
reference the existing symbols ExpressionUtils.isLiteral, ReferenceExpr, and
isEnumFieldReference when locating where to add this additional branch.

In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 178-187: The fast-fail branch currently only gathers codes from
unconditional deny rules (using getModelPolicies and isTrueExpr) and omits coded
allow-rule violations; fix by also collecting allow-rule codes and passing the
combined set to createRejectedByPolicyError: inspect
getModelPolicies(mutationModel, 'create') for policies with kind === 'allow' and
p.code (or use tryGetConstantPolicy to determine per-policy constant evaluation
if available), collect those allow codes that are not satisfied by the current
constant check, merge them with constantDenyCodes, and pass the merged
policyCodes array into createRejectedByPolicyError so callers receive the full
policyCodes set.
- Around line 1113-1149: The allow-policy diagnostic currently uses EXISTS(rows
WHERE allow_condition) and then negates it, which misreports batched post-update
failures; instead build the EXISTS expression to detect violating rows for allow
rules. Update the selections construction so that for policy.kind === 'allow'
you pass the negated condition into buildInnerExists (i.e., EXISTS(rows WHERE
NOT(allow_condition))) while keeping deny policies as-is; reference
compilePolicyCondition, buildInnerExists, codedPolicies and selections so you
can locate and change the conditional that creates SelectionNode entries,
leaving evaluatePolicyDiagnostics filtering logic unchanged.

In `@tests/e2e/orm/policy/crud/error-codes.test.ts`:
- Line 71: The test currently only checks rejection reason via
toBeRejectedByPolicy (e.g., await expect(db.foo.create({ data: { x: 0 }
})).toBeRejectedByPolicy(undefined, undefined)) but doesn’t assert that no
policy codes were returned; update the assertion to also explicitly assert the
absence of policyCodes on the rejection (for example, capture the thrown error
from db.foo.create and assert error.policyCodes is undefined or empty) and apply
the same change to the other occurrences noted (roughly the block around the
386-447 range); keep using the same matcher toBeRejectedByPolicy for the reason
check and add a separate assertion for policyCodes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2a92594a-ab5c-4567-9ecf-13efe7a9dbb9

📥 Commits

Reviewing files that changed from the base of the PR and between a31a32e and 9f30287.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • packages/language/src/validators/attribute-application-validator.ts
  • packages/orm/src/client/errors.ts
  • packages/plugins/policy/package.json
  • packages/plugins/policy/plugin.zmodel
  • packages/plugins/policy/src/options.ts
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/src/policy-handler.ts
  • packages/plugins/policy/src/types.ts
  • packages/plugins/policy/src/utils.ts
  • packages/plugins/policy/tsconfig.json
  • packages/testtools/src/types.d.ts
  • packages/testtools/src/vitest-ext.ts
  • tests/e2e/orm/policy/crud/error-codes.test.ts

Comment thread packages/language/src/validators/attribute-application-validator.ts Outdated
Comment thread packages/language/src/validators/attribute-application-validator.ts
Comment thread packages/plugins/policy/src/policy-handler.ts
Comment thread packages/plugins/policy/src/policy-handler.ts Outdated
Comment thread tests/e2e/orm/policy/crud/error-codes.test.ts Outdated
… errorCode support

- Fix bug: allow rules in diagnostic queries now use EXISTS(NOT condition)
  so a batch violation is detected even when some rows pass (previously
  NOT EXISTS(condition) was used, which only fired when ALL rows failed)
- Simplify evaluatePolicyDiagnostics: negate at build time so the filter
  is a uniform row[`$c${i}`] check instead of kind-branching
- Remove errorCode param from field-level @allow/@deny — codes are not
  surfaced at runtime for field-level policies, so the grammar and
  validator no longer accept them
- Remove the 200-char length limit check from validateCustomErrorCode
- Add 3 new tests: batch updateMany allow violation, enum code on
  post-update, mixed enum + string codes
- Tighten fetchPolicyCodes opt-out assertions to toBeRejectedByPolicy(undefined, [])

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
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.

♻️ Duplicate comments (1)
packages/plugins/policy/src/policy-handler.ts (1)

178-187: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the constant-deny shortcut aligned with the normal create diagnostics.

This branch still synthesizes policyCodes locally, so fetchPolicyCodes: false is ignored here, and any coded allow rules that also fail are omitted because only unconditional deny(true) codes are collected. Please only use this shortcut when codes are disabled; otherwise fall back to the regular pre-create check so create rejections produce the same policyCodes set everywhere.

Proposed fix
         } else if (constCondition === false) {
-            const policies = this.getModelPolicies(mutationModel, 'create');
-            const constantDenyCodes = policies
-                .filter((p) => p.kind === 'deny' && this.isTrueExpr(p.condition) && p.code)
-                .map((p) => p.code!);
-            throw createRejectedByPolicyError(
-                mutationModel,
-                RejectedByPolicyReason.NO_ACCESS,
-                undefined,
-                constantDenyCodes,
-            );
+            if (this.options.fetchPolicyCodes === false) {
+                throw createRejectedByPolicyError(mutationModel, RejectedByPolicyReason.NO_ACCESS);
+            }
+
+            await this.enforcePreCreatePolicy(node, mutationModel, isManyToManyJoinTable, proceed);
+            return;
         }
🤖 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` around lines 178 - 187, The
code currently short-circuits to collect only unconditional deny codes via
getModelPolicies + isTrueExpr and throws createRejectedByPolicyError with
undefined policyCodes; change this so the unconditional-deny shortcut is only
used when fetchPolicyCodes is explicitly disabled (e.g. this.fetchPolicyCodes
=== false or equivalent flag on the handler); otherwise invoke the regular
pre-create evaluation path used elsewhere in this class (the same routine that
computes the full policyCodes set for creates) and throw
createRejectedByPolicyError with that computed policyCodes and the appropriate
deny codes, keeping mutationModel and RejectedByPolicyReason.NO_ACCESS as
before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 178-187: The code currently short-circuits to collect only
unconditional deny codes via getModelPolicies + isTrueExpr and throws
createRejectedByPolicyError with undefined policyCodes; change this so the
unconditional-deny shortcut is only used when fetchPolicyCodes is explicitly
disabled (e.g. this.fetchPolicyCodes === false or equivalent flag on the
handler); otherwise invoke the regular pre-create evaluation path used elsewhere
in this class (the same routine that computes the full policyCodes set for
creates) and throw createRejectedByPolicyError with that computed policyCodes
and the appropriate deny codes, keeping mutationModel and
RejectedByPolicyReason.NO_ACCESS as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd83e4ef-ed0a-4af9-bdca-21ec8b9ba3a2

📥 Commits

Reviewing files that changed from the base of the PR and between 9f30287 and 0ffc32c.

📒 Files selected for processing (5)
  • packages/language/src/validators/attribute-application-validator.ts
  • packages/plugins/policy/plugin.zmodel
  • packages/plugins/policy/src/policy-handler.ts
  • packages/testtools/src/vitest-ext.ts
  • tests/e2e/orm/policy/crud/error-codes.test.ts
✅ Files skipped from review due to trivial changes (2)
  • tests/e2e/orm/policy/crud/error-codes.test.ts
  • packages/plugins/policy/plugin.zmodel
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/testtools/src/vitest-ext.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
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/crud/error-codes.test.ts (1)

377-397: ⚡ Quick win

Remove the duplicated enum post-update test case.

This block is a verbatim duplicate of Line 355-375 (same name + same body), which adds noise and makes failures ambiguous in reports.

Proposed cleanup
-    it('surfaces code from enum value on post-update violation', async () => {
-        const db = await createPolicyTestClient(
-            `
-    enum PolicyCode {
-        NEGATIVE_AFTER_UPDATE
-    }
-
-    model Foo {
-        id Int `@id`
-        x  Int
-        @@allow('create,read,update', true)
-        @@deny('post-update', x <= 0, NEGATIVE_AFTER_UPDATE)
-    }
-    `,
-        );
-        await db.foo.create({ data: { id: 1, x: 1 } });
-        await expect(db.foo.update({ where: { id: 1 }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [
-            'NEGATIVE_AFTER_UPDATE',
-        ]);
-        await expect(db.foo.update({ where: { id: 1 }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 });
-    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/orm/policy/crud/error-codes.test.ts` around lines 377 - 397, The
test "surfaces code from enum value on post-update violation" is duplicated;
remove the redundant test block (the second occurrence that uses
createPolicyTestClient with enum PolicyCode and the db.foo.create/db.foo.update
assertions) so only the original test remains—locate the duplicate by the test
title and references to createPolicyTestClient, PolicyCode, and db.foo.update
and delete that repeated it(...) block to avoid ambiguous/noisy test reports.
🤖 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/crud/error-codes.test.ts`:
- Around line 377-397: The test "surfaces code from enum value on post-update
violation" is duplicated; remove the redundant test block (the second occurrence
that uses createPolicyTestClient with enum PolicyCode and the
db.foo.create/db.foo.update assertions) so only the original test remains—locate
the duplicate by the test title and references to createPolicyTestClient,
PolicyCode, and db.foo.update and delete that repeated it(...) block to avoid
ambiguous/noisy test reports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5eef659e-6392-41f9-95a2-e3ad6b5cf19e

📥 Commits

Reviewing files that changed from the base of the PR and between 0ffc32c and c9ec333.

📒 Files selected for processing (1)
  • tests/e2e/orm/policy/crud/error-codes.test.ts

Adds post-check logic that runs when 0 rows are affected by an update or
delete, distinguishing "row not found" from "denied by policy" and
collecting matching policy codes in a single combined diagnostic query.
Extends fetchPolicyCodes query-level override to $delete, and adds
corresponding E2E tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
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 (2)
packages/plugins/policy/src/policy-handler.ts (1)

261-317: postModelLevelCheck adds a SELECT round-trip for every 0-rows update/delete — including genuine "not found" cases.

The isTrueNode early-return (line 271) avoids the extra query when no policies exist. But when any policy is present, every 0-rows UPDATE/DELETE — whether due to policy denial or a plain "record not found" — pays the cost of a SELECT EXISTS(…) before the handler can determine which case applies. For workloads where "record not found" is the common path (e.g., concurrent delete of already-deleted rows), this doubles the DB round-trips for those calls.

If fetchPolicyCodes diagnostics are not needed, you can already set fetchPolicyCodes: false globally to skip code collection, but the existence check still runs. Consider exposing a separate opt-out (e.g., detectPolicyDenial: false) for cases where callers are happy with a silent 0-row result.

🤖 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` around lines 261 - 317,
postModelLevelCheck currently issues an extra SELECT EXISTS whenever any
model-level policy exists, even when callers don't need denial detection; add a
new boolean option (e.g., detectPolicyDenial) defaulting to true and
short-circuit the function when it's false. Specifically, update the method
postModelLevelCheck to return immediately if this.options.detectPolicyDenial ===
false (place this check after the existing isTrueNode(modelLevelFilter) check),
and ensure callers can set options.detectPolicyDenial to false to avoid the
EXISTS/query and coded policy evaluation; keep existing behavior when the flag
is true and document the new option default.
tests/e2e/orm/policy/crud/error-codes.test.ts (1)

109-136: ⚡ Quick win

Missing regression test: update/delete of a genuinely non-existent row should not throw after postModelLevelCheck is added.

postModelLevelCheck now runs for every 0-rows UPDATE/DELETE when policies exist. The method's $exists check is the safety net that prevents a "row not found" from being incorrectly raised as REJECTED_BY_POLICY. This path is not currently exercised by any test in this file.

Consider adding a case like:

// verify "row not found" does not throw REJECTED_BY_POLICY
await expect(db.foo.update({ where: { id: 9999 }, data: { x: 1 } })).resolves.toMatchObject({});
await expect(db.foo.delete({ where: { id: 9999 } })).resolves.toMatchObject({});

into one of the existing schemas (e.g., the deny/allow update test) to confirm $exists=false correctly suppresses the error.

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

In `@tests/e2e/orm/policy/crud/error-codes.test.ts` around lines 109 - 136, Add
assertions to ensure that updating/deleting a truly non-existent row does not
raise REJECTED_BY_POLICY by exercising the postModelLevelCheck $exists path: in
the existing test (the one that sets up model Foo and creates neg/noY/ok), after
the current expectations add two checks using db.foo.update and db.foo.delete
with a non-existent id (e.g., 9999) and assert they resolve (e.g., await
expect(db.foo.update({ where: { id: 9999 }, data: { x: 1 }
})).resolves.toMatchObject({}) and similarly for db.foo.delete) so that
postModelLevelCheck’s $exists=false branch is covered and does not throw.
🤖 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 141-148: This changes post-check behavior to throw via
postModelLevelCheck when result.numAffectedRows is 0, which breaks callers that
relied on a silent 0 return and also leaves UPDATE…FROM unguarded; update the
code to (1) document the behavioral change in the changelog/migration notes that
model-level policy denials for update/delete now throw REJECTED_BY_POLICY
instead of returning numAffectedRows = 0 so callers can migrate, and (2) add the
same guard used for deletes to the update branch so UPDATE queries that use
FROM/join are skipped (e.g., change the condition to detect
UpdateQueryNode.is(node) && !(node.from ?? node.using) before calling
postModelLevelCheck), keeping the existing use of node.where?.where,
trueNode(this.dialect), mutationModel, and proceed.

---

Nitpick comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 261-317: postModelLevelCheck currently issues an extra SELECT
EXISTS whenever any model-level policy exists, even when callers don't need
denial detection; add a new boolean option (e.g., detectPolicyDenial) defaulting
to true and short-circuit the function when it's false. Specifically, update the
method postModelLevelCheck to return immediately if
this.options.detectPolicyDenial === false (place this check after the existing
isTrueNode(modelLevelFilter) check), and ensure callers can set
options.detectPolicyDenial to false to avoid the EXISTS/query and coded policy
evaluation; keep existing behavior when the flag is true and document the new
option default.

In `@tests/e2e/orm/policy/crud/error-codes.test.ts`:
- Around line 109-136: Add assertions to ensure that updating/deleting a truly
non-existent row does not raise REJECTED_BY_POLICY by exercising the
postModelLevelCheck $exists path: in the existing test (the one that sets up
model Foo and creates neg/noY/ok), after the current expectations add two checks
using db.foo.update and db.foo.delete with a non-existent id (e.g., 9999) and
assert they resolve (e.g., await expect(db.foo.update({ where: { id: 9999 },
data: { x: 1 } })).resolves.toMatchObject({}) and similarly for db.foo.delete)
so that postModelLevelCheck’s $exists=false branch is covered and does not
throw.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b0f06b43-07f3-4a7c-aa1c-5f7302c8f4c1

📥 Commits

Reviewing files that changed from the base of the PR and between c9ec333 and a45b5cc.

📒 Files selected for processing (4)
  • packages/orm/src/client/errors.ts
  • packages/plugins/policy/src/plugin.ts
  • packages/plugins/policy/src/policy-handler.ts
  • tests/e2e/orm/policy/crud/error-codes.test.ts

Comment thread packages/plugins/policy/src/policy-handler.ts Outdated
…wsCheck

Rename and tighten the zero-rows diagnostic helper for update/delete:
- Skip the diagnostic query entirely when no policies carry an error code,
  removing an unnecessary round-trip for the common case.
- Fix BigInt comparison: use `> 0` negation instead of `=== 0` so
  numAffectedRows is handled correctly across drivers.
- Reorder delete/update branches so the more specific guard (delete has
  no `using` restriction) runs first.
- Inline the fetchPolicyCodes guard into a single early-exit path.

Tests: add "does not throw for nonexistent row" cases for update and delete,
and a focused test asserting the opt-in behaviour — rules without an errorCode
keep the existing NotFound error, rules with an errorCode surface
RejectedByPolicy instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/plugins/policy/src/policy-handler.ts (1)

213-258: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Field-level update policy codes are still dropped here.

This path collapses all touched @allow/@deny field rules into a single boolean and then throws a generic NO_ACCESS error. Since getFieldPolicies() now parses code, any rejection caused by a coded field-level update policy loses its policyCodes, so the new feature only works for model-level update rules.

🤖 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` around lines 213 - 258, The
field-level update path currently collapses all field `@allow/`@deny rules into a
single boolean (fieldLevelFilter) and throws a generic NO_ACCESS, losing
policyCodes parsed by getFieldPolicies(); update the flow to surface coded
field-policy rejections: modify buildFieldPolicyFilter (or add a companion
method) so it can produce both a boolean filter and, when a row would be
rejected by a field rule, the associated policy code(s); change the
violatingRowsQuery to SELECT the offending policy code(s) (instead of just
lit(1)) for rows matching conjunction([updateFilter, NOT fieldLevelFilter]);
then read those codes from preUpdateResult and pass them into
createRejectedByPolicyError (adding a policyCodes argument) so the thrown error
includes the field-level policy codes (referencing fieldsToUpdate,
buildFieldPolicyFilter, fieldLevelFilter, updateFilter, violatingRowsQuery,
preUpdateResult, and createRejectedByPolicyError).
♻️ Duplicate comments (1)
packages/plugins/policy/src/policy-handler.ts (1)

143-148: ⚠️ Potential issue | 🟠 Major

Guard the zero-row diagnostic for joined UPDATE/DELETE mutations.

postMutationZeroRowsCheck() rebuilds a SELECT ... FROM <model> and reuses node.where verbatim. For UPDATE ... FROM / DELETE ... USING, predicates can reference joined tables, so this path can generate an invalid diagnostic query once numAffectedRows is 0.

💡 Suggested guard
-        if (!((result.numAffectedRows ?? 0) > 0)) {
-            if (DeleteQueryNode.is(node)) {
+        if (!((result.numAffectedRows ?? 0) > 0)) {
+            if (DeleteQueryNode.is(node) && !node.using) {
                 await this.postMutationZeroRowsCheck(mutationModel, 'delete', node.where?.where, proceed);
-            } else if (UpdateQueryNode.is(node)) {
+            } else if (UpdateQueryNode.is(node) && !node.from) {
                 await this.postMutationZeroRowsCheck(mutationModel, 'update', node.where?.where, proceed);
             }
         }
#!/bin/bash
set -e

echo "=== zero-row call site ==="
sed -n '141,148p' packages/plugins/policy/src/policy-handler.ts

echo
echo "=== diagnostic query shape ==="
sed -n '279,309p' packages/plugins/policy/src/policy-handler.ts

echo
echo "=== joined mutation support in the transformer ==="
sed -n '604,659p' packages/plugins/policy/src/policy-handler.ts
🤖 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` around lines 143 - 148, The
zero-row diagnostic call should be skipped for joined UPDATE/DELETE mutations
because postMutationZeroRowsCheck rebuilds a SELECT using node.where verbatim
and joined predicates can reference other tables; update the conditional around
DeleteQueryNode.is(node) and UpdateQueryNode.is(node) to first detect joined
mutations (e.g., presence of node.from, node.using, or any join
entries—implement a small helper like hasJoins(node) or inline checks such as
node.from?.length>0 || node.using?.length>0 || node.from != null) and only call
this.postMutationZeroRowsCheck(mutationModel, 'delete'|'update',
node.where?.where, proceed) when no joins are present; if joins exist, skip the
diagnostic to avoid generating invalid SELECT queries.
🤖 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 286-318: The diagnostic existence query and policy-code checks are
still executed even when fetchPolicyCodes is false; add an early guard so when
this.options.fetchPolicyCodes === false you skip building/running the diagnostic
(i.e., avoid constructing codeSelections and avoid calling proceed/await on the
SelectQueryNode) and do not call createRejectedByPolicyError or throw
RejectedByPolicyReason.NO_ACCESS; implement this by returning immediately (or
otherwise short-circuiting) before the code that references selectedPolicies,
codeSelections, proceed(rowExistsInner...), and createRejectedByPolicyError so
no diagnostic query runs and no throw occurs when fetchPolicyCodes is disabled.

---

Outside diff comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 213-258: The field-level update path currently collapses all field
`@allow/`@deny rules into a single boolean (fieldLevelFilter) and throws a generic
NO_ACCESS, losing policyCodes parsed by getFieldPolicies(); update the flow to
surface coded field-policy rejections: modify buildFieldPolicyFilter (or add a
companion method) so it can produce both a boolean filter and, when a row would
be rejected by a field rule, the associated policy code(s); change the
violatingRowsQuery to SELECT the offending policy code(s) (instead of just
lit(1)) for rows matching conjunction([updateFilter, NOT fieldLevelFilter]);
then read those codes from preUpdateResult and pass them into
createRejectedByPolicyError (adding a policyCodes argument) so the thrown error
includes the field-level policy codes (referencing fieldsToUpdate,
buildFieldPolicyFilter, fieldLevelFilter, updateFilter, violatingRowsQuery,
preUpdateResult, and createRejectedByPolicyError).

---

Duplicate comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 143-148: The zero-row diagnostic call should be skipped for joined
UPDATE/DELETE mutations because postMutationZeroRowsCheck rebuilds a SELECT
using node.where verbatim and joined predicates can reference other tables;
update the conditional around DeleteQueryNode.is(node) and
UpdateQueryNode.is(node) to first detect joined mutations (e.g., presence of
node.from, node.using, or any join entries—implement a small helper like
hasJoins(node) or inline checks such as node.from?.length>0 ||
node.using?.length>0 || node.from != null) and only call
this.postMutationZeroRowsCheck(mutationModel, 'delete'|'update',
node.where?.where, proceed) when no joins are present; if joins exist, skip the
diagnostic to avoid generating invalid SELECT queries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c62a3f6-33de-473f-bfc1-56597ff21101

📥 Commits

Reviewing files that changed from the base of the PR and between a45b5cc and 0ffadd4.

📒 Files selected for processing (4)
  • packages/plugins/policy/src/policy-handler.ts
  • tests/e2e/orm/policy/crud/delete.test.ts
  • tests/e2e/orm/policy/crud/error-codes.test.ts
  • tests/e2e/orm/policy/crud/update.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/orm/policy/crud/error-codes.test.ts

Comment thread packages/plugins/policy/src/policy-handler.ts Outdated
@Azzerty23
Copy link
Copy Markdown
Contributor Author

For update and delete policy violations, the ORM was silently returning "not found" — no way to tell if the row was missing or just denied, and no way to get the error code.

In the last 2 commits, I added support to these operations (update/delete) by adding postMutationZeroRowsCheck: when numAffectedRows == 0, fire a single query to check if the row actually exists. If it does, the policy blocked it — throw REJECTED_BY_POLICY with the relevant codes. If not, stay silent and let the ORM handle "not found" as usual.

Opt-in only — the check only runs when the model has at least one policy with an errorCode. No codes = no behavior change. Zero breaking changes for existing schemas... but can be confusing...

Performance — no extra query on the happy path. One combined SELECT (existence + all code diagnostics) on the error path.

Azzerty23 and others added 4 commits May 2, 2026 13:36
…ctations

Move the fetchPolicyCodes===false early-return before policy filtering so the
diagnostic query is skipped entirely for update/delete, making those operations
behave identically to a model with no error codes (NOT_FOUND). Update test
expectations accordingly and add an explicit equivalence test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend policy error code surfacing to `findFirst`/`findUnique` (and their
`OrThrow` variants): when the query returns 0 rows, a diagnostic query now
checks whether the row exists but was filtered by a deny/allow rule that
carries an `errorCode`. If so, the handler throws `REJECTED_BY_POLICY`
with the relevant codes instead of silently returning `null`.

`findMany` is unaffected — it continues to use filter-based enforcement
and returns an empty array for denied rows. This holds even for
`findMany({ take: 1 })`, which generates LIMIT 1 SQL but remains a
multi-row ORM operation by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r dialects without RETURNING

On dialects lacking RETURNING support (e.g. MySQL), the ORM pre-loads
entity ID fields before an UPDATE to identify the row for re-reading.
This pre-load ran through onKyselyQuery plugin hooks, causing a
read-policy denial to surface as "Record not found" before the UPDATE
executed — masking the correct error code.

Introduce internalQueryContextStorage (AsyncLocalStorage) to mark these
internal pre-load queries; ZenStackQueryExecutor now skips all
onKyselyQuery hooks when the flag is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Azzerty23
Copy link
Copy Markdown
Contributor Author

Azzerty23 commented May 2, 2026

I tried to implement as much as I could. I hope I didn't head in the wrong direction — I ultimately stayed pretty close to the approach recommended in the issue, except that I don't override the error message but instead add all error codes (which can be enums) concerned by the policy violation. I'm happy to change this if it doesn't fit (keeping the same generic message regardless of whether you opt into retrieving error codes seems convenient to me).

The last commit is a fix that came directly from Claude for two tests that were failing with MySQL — it deserves a review...

@Azzerty23
Copy link
Copy Markdown
Contributor Author

Azzerty23 commented May 4, 2026

By revisiting the PR description, I've just realized that findFirst / findUnique should never throw... (→ null). It's fixed now.

@Azzerty23 Azzerty23 force-pushed the feat/policy-custom-error-codes branch 7 times, most recently from 6055f81 to 3b0e78f Compare May 4, 2026 16:35
@Azzerty23 Azzerty23 force-pushed the feat/policy-custom-error-codes branch 7 times, most recently from b87f004 to 502f209 Compare May 4, 2026 21:13
Azzerty23 and others added 3 commits May 4, 2026 23:20
Add a browser export condition to @zenstackhq/orm so that browser bundlers
(Turbopack, webpack) use an empty stub instead of dist/index.mjs, which
imports node:async_hooks via internal-context.ts. Also include the Node.js
version in the CI pnpm cache key to prevent native module (better-sqlite3)
ABI mismatches after a Node.js upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Azzerty23 Azzerty23 force-pushed the feat/policy-custom-error-codes branch from 502f209 to 58f0efb Compare May 4, 2026 21:23
Azzerty23 and others added 7 commits May 5, 2026 11:12
On non-RETURNING dialects (MySQL), the ORM pre-loads entity IDs before
an UPDATE. If the row is read-denied, the pre-load returns null and the
UPDATE never runs, masking the real update-deny error code.

Adds `requiresUpdatePreloadBypassReadPolicy` to the dialect interface
(true for MySQL), `executeQueryDirect` to ZenStackQueryExecutor to run
a query without onKyselyQuery interceptors, and `readUniqueDirect` in
the base operation handler to use it for the pre-load step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rectly to executeQueryDirect

Eliminates the withConnectionProvider/SingleConnectionProvider/scopedExecutor
indirection in readUniqueDirect. executeQueryDirect now takes a DatabaseConnection
parameter and delegates straight to internalExecuteQuery, removing the redundant
provideConnection wrapper and double ensureProperQueryResult call. Also removes the
dead ?? null on the bypass branch and trims the 8-line narration comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eries for dialects without RETURNING"

This reverts commit 885a04c.
…t map

Eliminates the policyContextStorage AsyncLocalStorage by threading a
per-operation Map<string,unknown> through the onQuery/onKyselyQuery hook
chain instead. This removes the node:async_hooks dependency from the
policy plugin (and the orm package browser stub that worked around it),
letting the Next.js sample drop serverExternalPackages overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Azzerty23 Azzerty23 force-pushed the feat/policy-custom-error-codes branch from e8aa787 to 2ad4be0 Compare May 5, 2026 15:04
… via direct flag

Remove the requiresUpdatePreloadBypassReadPolicy dialect flag and the
duplicate readUniqueDirect method. All non-RETURNING dialects now use
the direct=true path in read/readUnique, which routes connection
acquisition through the outer executor while bypassing onKyselyQuery
interceptors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Azzerty23 Azzerty23 force-pushed the feat/policy-custom-error-codes branch from 2ad4be0 to f3f126c Compare May 5, 2026 16:37
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.

[Feature Request] Allow custom messages in policies

1 participant