Skip to content

Commit 6055f81

Browse files
committed
fix(policy): restrict error-code surfacing to OrThrow read variants
findFirst/findUnique always return null on policy denial (filter-based); only findFirstOrThrow/findUniqueOrThrow surface RejectedByPolicy codes. Rename SingleRowReadOperations → SingleRowOrThrowOperations to reflect this.
1 parent 48258b0 commit 6055f81

4 files changed

Lines changed: 99 additions & 37 deletions

File tree

packages/orm/src/client/crud/operations/base.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,14 @@ export const AllReadOperations = [...CoreReadOperations, 'findUniqueOrThrow', 'f
170170
export type AllReadOperations = (typeof AllReadOperations)[number];
171171

172172
/**
173-
* List of single-row read operations — `findUnique`/`findFirst` and their 'orThrow' variants.
173+
* List of single-row read operations that throw when no row is found.
174174
*/
175-
export const SingleRowReadOperations = ['findUnique', 'findFirst', 'findUniqueOrThrow', 'findFirstOrThrow'] as const;
175+
export const SingleRowOrThrowOperations = ['findUniqueOrThrow', 'findFirstOrThrow'] as const;
176176

177177
/**
178-
* List of single-row read operations.
178+
* List of single-row read operations that throw when no row is found.
179179
*/
180-
export type SingleRowReadOperations = (typeof SingleRowReadOperations)[number];
180+
export type SingleRowOrThrowOperations = (typeof SingleRowOrThrowOperations)[number];
181181

182182
/**
183183
* List of all write operations - simply an alias of CoreWriteOperations.
@@ -1225,9 +1225,8 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
12251225
// not a user-visible read — so it must bypass onKyselyQuery plugin hooks. Without the
12261226
// bypass, a read-policy denial would surface as "Record not found" here before the
12271227
// UPDATE runs, preventing the policy plugin from emitting the correct error code.
1228-
combinedWhere = await internalQueryContextStorage.run(
1229-
{ bypassOnKyselyHooks: true },
1230-
() => loadThisEntity(),
1228+
combinedWhere = await internalQueryContextStorage.run({ bypassOnKyselyHooks: true }, () =>
1229+
loadThisEntity(),
12311230
);
12321231
if (!combinedWhere) {
12331232
return null;

packages/orm/src/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export {
1313
CoreReadOperations,
1414
CoreUpdateOperations,
1515
CoreWriteOperations,
16-
SingleRowReadOperations,
16+
SingleRowOrThrowOperations,
1717
} from './crud/operations/base';
1818
export { InputValidator } from './crud/validator';
1919
export { ORMError, ORMErrorReason, RejectedByPolicyReason } from './errors';

packages/plugins/policy/src/policy-handler.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { invariant } from '@zenstackhq/common-helpers';
22
import type { BaseCrudDialect, ClientContract, CRUD_EXT, ProceedKyselyQueryFunction } from '@zenstackhq/orm';
3-
import { CoreWriteOperations, getCrudDialect, QueryUtils, RejectedByPolicyReason, SchemaUtils, SingleRowReadOperations } from '@zenstackhq/orm';
3+
import {
4+
getCrudDialect,
5+
QueryUtils,
6+
RejectedByPolicyReason,
7+
SchemaUtils,
8+
SingleRowOrThrowOperations,
9+
} from '@zenstackhq/orm';
410
import {
511
ExpressionUtils,
612
type BuiltinType,
@@ -60,8 +66,7 @@ import {
6066
trueNode,
6167
} from './utils';
6268

63-
const SINGLE_ROW_READ_OPERATIONS = new Set<string>(SingleRowReadOperations);
64-
const ORM_WRITE_OPERATIONS = new Set<string>(CoreWriteOperations);
69+
const SINGLE_ROW_OR_THROW_OPERATIONS = new Set<string>(SingleRowOrThrowOperations);
6570

6671
export type CrudQueryNode = SelectQueryNode | InsertQueryNode | UpdateQueryNode | DeleteQueryNode;
6772

@@ -99,8 +104,11 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
99104
if (!this.isMutationQueryNode(node)) {
100105
const selectNode = node as SelectQueryNode;
101106
const result = await proceed(this.transformNode(node));
102-
// When 0 rows returned on a single-row read, distinguish "not found" from policy denial
103-
if (result.rows.length === 0 && SINGLE_ROW_READ_OPERATIONS.has(policyContextStorage.getStore()?.operation ?? '')) {
107+
// When 0 rows returned on a throwing single-row read (findFirstOrThrow/findUniqueOrThrow), distinguish "not found" from policy denial
108+
if (
109+
result.rows.length === 0 &&
110+
SINGLE_ROW_OR_THROW_OPERATIONS.has(policyContextStorage.getStore()?.operation ?? '')
111+
) {
104112
await this.postReadZeroRowsCheck(selectNode, proceed);
105113
}
106114
return result;
@@ -268,9 +276,7 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
268276
}
269277
}
270278

271-
// Called when a single-row read returns 0 rows. Skips internal reads (read-back after mutation).
272279
private async postReadZeroRowsCheck(node: SelectQueryNode, proceed: ProceedKyselyQueryFunction): Promise<void> {
273-
if (ORM_WRITE_OPERATIONS.has(policyContextStorage.getStore()?.operation ?? '')) return;
274280
if (!node.from || node.from.froms.length !== 1) return;
275281
const extractedTable = this.extractTableName(node.from.froms[0]!);
276282
if (!extractedTable) return;

tests/e2e/orm/policy/crud/error-codes.test.ts

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ORMError } from '@zenstackhq/orm';
12
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
23
import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools';
34
import { describe, expect, it } from 'vitest';
@@ -10,7 +11,11 @@ describe('Policy error code tests', () => {
1011
// │ allow rule fails (string code) │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │
1112
// │ constant deny (true condition) │ ✓ │ │ │ │ │
1213
// │ no errorCode on rule │ ✓ │ │ │ │ │
13-
// │ code opt-in: NotFound vs RejectedByPol. │ │ │ ✓ │ ✓ │ ✓ │
14+
// │ policyCodes undefined when no codes │ ✓ │ │ │ │ │
15+
// │ code opt-in: NotFound vs RejByPol. │ │ │ ✓ │ ✓ │ │
16+
// │ findFirst/findUnique: always null │ │ │ │ │ ✓ │
17+
// │ findXOrThrow: deny/allow rule fires │ │ │ │ │ ✓ │
18+
// │ findXOrThrow: NOT_FOUND vs RejByPol. │ │ │ │ │ ✓ │
1419
// │ findMany not affected (filter-based) │ │ │ │ │ ✓ │
1520
// │ findMany({ take:1 }) not affected │ │ │ │ │ ✓ │
1621
// │ multiple deny rules fire │ ✓ │ ✓ │ │ │ │
@@ -75,6 +80,26 @@ describe('Policy error code tests', () => {
7580
await expect(db.foo.create({ data: { x: 0 } })).toBeRejectedByPolicy(undefined, []);
7681
});
7782

83+
it('policyCodes is undefined (not []) when no error codes are configured', async () => {
84+
const db = await createPolicyTestClient(
85+
`
86+
model Foo {
87+
id Int @id @default(autoincrement())
88+
x Int
89+
@@deny('create', x <= 0)
90+
@@allow('create,read', true)
91+
}
92+
`,
93+
);
94+
try {
95+
await db.foo.create({ data: { x: 0 } });
96+
expect.fail('expected error');
97+
} catch (err) {
98+
expect(err).toBeInstanceOf(ORMError);
99+
expect((err as ORMError).policyCodes).toBeUndefined();
100+
}
101+
});
102+
78103
// ── opt-in: adding a code changes error type from NotFound to RejectedByPolicy ──
79104

80105
it('blocked update/delete yields NotFound without code, RejectedByPolicy with code', async () => {
@@ -104,7 +129,27 @@ model Foo {
104129

105130
// ── read: single rule, single code ───────────────────────────────────────
106131

107-
it('surfaces code from deny/allow rule on findFirst/findUnique violation', async () => {
132+
it('findFirst/findUnique always return null on policy violation, never throw', async () => {
133+
const db = await createPolicyTestClient(
134+
`
135+
model Foo {
136+
id Int @id @default(autoincrement())
137+
x Int
138+
@@allow('create', true)
139+
@@allow('read', x > 0, 'NEED_POSITIVE_X')
140+
}
141+
`,
142+
);
143+
const blocked = await db.$unuseAll().foo.create({ data: { x: 0 } });
144+
await expect(db.foo.findFirst({ where: { id: blocked.id } })).resolves.toBeNull();
145+
await expect(db.foo.findUnique({ where: { id: blocked.id } })).resolves.toBeNull();
146+
// happy path
147+
const visible = await db.$unuseAll().foo.create({ data: { x: 1 } });
148+
await expect(db.foo.findFirst({ where: { id: visible.id } })).resolves.toMatchObject({ x: 1 });
149+
await expect(db.foo.findUnique({ where: { id: visible.id } })).resolves.toMatchObject({ x: 1 });
150+
});
151+
152+
it('surfaces code from deny/allow rule on findFirstOrThrow/findUniqueOrThrow violation', async () => {
108153
const db = await createPolicyTestClient(
109154
`
110155
model Foo {
@@ -123,17 +168,17 @@ model Foo {
123168
const positiveXY = await unprotected.foo.create({ data: { x: 1, y: 1 } });
124169

125170
// deny code: x <= 0 triggers deny rule
126-
await expect(db.foo.findFirst({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']);
127-
await expect(db.foo.findUnique({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']);
171+
await expect(db.foo.findFirstOrThrow({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']);
172+
await expect(db.foo.findUniqueOrThrow({ where: { id: zeroX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X']);
128173
// allow code: y is not > 0 so allow rule fails
129-
await expect(db.foo.findFirst({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']);
130-
await expect(db.foo.findUnique({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']);
174+
await expect(db.foo.findFirstOrThrow({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']);
175+
await expect(db.foo.findUniqueOrThrow({ where: { id: zeroY.id } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_Y']);
131176
// happy path
132-
await expect(db.foo.findFirst({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 });
133-
await expect(db.foo.findUnique({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 });
177+
await expect(db.foo.findFirstOrThrow({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 });
178+
await expect(db.foo.findUniqueOrThrow({ where: { id: positiveXY.id } })).resolves.toMatchObject({ x: 1, y: 1 });
134179
});
135180

136-
it('blocked read yields null without code, RejectedByPolicy with code', async () => {
181+
it('blocked findFirstOrThrow/findUniqueOrThrow yields NOT_FOUND without code, REJECTED_BY_POLICY with code', async () => {
137182
const schema = (withCode: boolean) => `
138183
model Foo {
139184
id Int @id @default(autoincrement())
@@ -142,19 +187,19 @@ model Foo {
142187
@@allow('read', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''})
143188
}
144189
`;
145-
// Without error code: policy filters the row silently → null (not found)
190+
// Without error code: policy filters the row silently → NOT_FOUND (orThrow always throws)
146191
const dbNoCode = await createPolicyTestClient(schema(false));
147192
const noCodeRow = await dbNoCode.$unuseAll().foo.create({ data: { x: 0 } });
148-
await expect(dbNoCode.foo.findUnique({ where: { id: noCodeRow.id } })).resolves.toBeNull();
149-
await expect(dbNoCode.foo.findFirst({ where: { id: noCodeRow.id } })).resolves.toBeNull();
193+
await expect(dbNoCode.foo.findUniqueOrThrow({ where: { id: noCodeRow.id } })).toBeRejectedNotFound();
194+
await expect(dbNoCode.foo.findFirstOrThrow({ where: { id: noCodeRow.id } })).toBeRejectedNotFound();
150195

151-
// With error code: the plugin detects the policy block → RejectedByPolicy
196+
// With error code: the plugin detects the policy block → REJECTED_BY_POLICY
152197
const dbWithCode = await createPolicyTestClient(schema(true));
153198
const withCodeRow = await dbWithCode.$unuseAll().foo.create({ data: { x: 0 } });
154-
await expect(dbWithCode.foo.findUnique({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [
199+
await expect(dbWithCode.foo.findUniqueOrThrow({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [
155200
'NEED_POSITIVE_X',
156201
]);
157-
await expect(dbWithCode.foo.findFirst({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [
202+
await expect(dbWithCode.foo.findFirstOrThrow({ where: { id: withCodeRow.id } })).toBeRejectedByPolicy(undefined, [
158203
'NEED_POSITIVE_X',
159204
]);
160205
});
@@ -653,8 +698,10 @@ model Foo {
653698
await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, []);
654699
const positiveX = await db.foo.create({ data: { x: 1, y: 1 } });
655700
const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } });
656-
// read: diagnostic query skipped entirely — behaves as if no codes → null (filter-based)
701+
// read: diagnostic query skipped entirely — behaves as if no codes → null/NOT_FOUND
657702
await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull();
703+
await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound();
704+
await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound();
658705
// update/delete: diagnostic query skipped entirely — behaves as if no codes → NOT_FOUND
659706
await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 } })).toBeRejectedNotFound();
660707
await expect(db.foo.delete({ where: { id: negX.id } })).toBeRejectedNotFound();
@@ -693,10 +740,14 @@ model Foo {
693740
await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_Y_CREATE']);
694741
const positiveX = await db.foo.create({ data: { x: 1, y: 1 } });
695742
const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } });
696-
// read: flag skips diagnostic query entirely → null (filter-based, same as no codes)
743+
// read: flag skips diagnostic query entirely → null/NOT_FOUND (filter-based, same as no codes)
697744
await expect(db.foo.findFirst({ where: { id: negX.id }, fetchPolicyCodes: false })).resolves.toBeNull();
698-
// read: without flag, codes surface
699-
await expect(db.foo.findFirst({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_READ']);
745+
await expect(db.foo.findFirstOrThrow({ where: { id: negX.id }, fetchPolicyCodes: false })).toBeRejectedNotFound();
746+
await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id }, fetchPolicyCodes: false })).toBeRejectedNotFound();
747+
// read: findFirst always returns null; OrThrow variants surface codes
748+
await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull();
749+
await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_READ']);
750+
await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedByPolicy(undefined, ['NEGATIVE_X_READ']);
700751
// update: flag skips diagnostic query entirely → NOT_FOUND (same as no codes defined)
701752
await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 }, fetchPolicyCodes: false })).toBeRejectedNotFound();
702753
// update: without flag, codes surface
@@ -785,12 +836,18 @@ model Foo {
785836
await expect(db.foo.create({ data: { x: 1, y: 0 } })).toBeRejectedByPolicy(undefined, []);
786837
const positiveX = await db.foo.create({ data: { x: 1, y: 1 } });
787838
const negX = await db.$unuseAll().foo.create({ data: { x: -1, y: 1 } });
788-
// read: query-level true re-enables codes despite plugin false
789-
await expect(db.foo.findFirst({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [
839+
// read: findFirst always returns null; query-level true re-enables codes for OrThrow variants
840+
await expect(db.foo.findFirst({ where: { id: negX.id }, fetchPolicyCodes: true })).resolves.toBeNull();
841+
await expect(db.foo.findFirstOrThrow({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [
842+
'NEGATIVE_X_READ',
843+
]);
844+
await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id }, fetchPolicyCodes: true })).toBeRejectedByPolicy(undefined, [
790845
'NEGATIVE_X_READ',
791846
]);
792-
// read: without override, codes are suppressed → null (filter-based)
847+
// read: without override, codes are suppressed → null/NOT_FOUND (filter-based)
793848
await expect(db.foo.findFirst({ where: { id: negX.id } })).resolves.toBeNull();
849+
await expect(db.foo.findFirstOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound();
850+
await expect(db.foo.findUniqueOrThrow({ where: { id: negX.id } })).toBeRejectedNotFound();
794851
// update: query-level true re-enables codes despite plugin false
795852
await expect(db.foo.update({ where: { id: negX.id }, data: { x: 0 }, fetchPolicyCodes: true })).toBeRejectedByPolicy(
796853
undefined,

0 commit comments

Comments
 (0)