Skip to content

Commit 0ffadd4

Browse files
Azzerty23claude
andcommitted
refactor(policy): replace postModelLevelCheck with postMutationZeroRowsCheck
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>
1 parent a45b5cc commit 0ffadd4

4 files changed

Lines changed: 119 additions & 48 deletions

File tree

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

Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,13 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
138138

139139
// #region Post mutation work
140140

141-
// post-check: detect policy denial when 0 rows affected (replaces pre-check for update/delete)
142-
if (!(result.numAffectedRows ?? 0n)) {
143-
if (UpdateQueryNode.is(node)) {
144-
await this.postModelLevelCheck(mutationModel, 'update', node.where?.where ?? trueNode(this.dialect), proceed);
145-
} else if (DeleteQueryNode.is(node) && !node.using) {
146-
await this.postModelLevelCheck(mutationModel, 'delete', node.where?.where ?? trueNode(this.dialect), proceed);
141+
// When 0 rows affected, distinguish "row not found" from "row denied by policy"
142+
// Use > 0 negation (not === 0) because numAffectedRows is BigInt in some drivers
143+
if (!((result.numAffectedRows ?? 0) > 0)) {
144+
if (DeleteQueryNode.is(node)) {
145+
await this.postMutationZeroRowsCheck(mutationModel, 'delete', node.where?.where, proceed);
146+
} else if (UpdateQueryNode.is(node)) {
147+
await this.postMutationZeroRowsCheck(mutationModel, 'update', node.where?.where, proceed);
147148
}
148149
}
149150

@@ -258,62 +259,63 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
258259
}
259260
}
260261

261-
// Post-check for model-level update/delete policy enforcement.
262-
// Runs only when 0 rows were affected: distinguishes "row not found" from "row denied by policy".
263-
// Combines existence check and diagnostic into a single query.
264-
private async postModelLevelCheck(
265-
mutationModel: string,
262+
// Checks if any row matching the original WHERE exists without the policy filter.
263+
// Called when numAffectedRows == 0 for UPDATE or DELETE.
264+
// If a row exists but was filtered by policy → throws REJECTED_BY_POLICY with codes.
265+
// If no row matches → returns silently (ORM layer handles "not found").
266+
// Combines existence check and code diagnostics into a single query.
267+
private async postMutationZeroRowsCheck(
268+
model: string,
266269
operation: 'update' | 'delete',
267-
userWhere: OperationNode,
270+
originalWhere: OperationNode | undefined,
268271
proceed: ProceedKyselyQueryFunction,
269272
) {
270-
const modelLevelFilter = this.buildPolicyFilter(mutationModel, undefined, operation);
271-
if (isTrueNode(modelLevelFilter)) {
272-
return;
273-
}
273+
if (this.isManyToManyJoinTable(model)) return;
274+
if (this.tryGetConstantPolicy(model, operation) === true) return;
275+
const codedPolicies = this.getModelPolicies(model, operation).filter((p) => p.code);
276+
// Skip if no policies carry an error code — nothing to surface.
277+
if (codedPolicies.length === 0) return;
274278

275-
const codedPolicies =
276-
this.options.fetchPolicyCodes !== false
277-
? this.getModelPolicies(mutationModel, operation).filter((p) => p.code)
278-
: [];
279+
const whereCondition = originalWhere ?? trueNode(this.dialect);
279280

280-
// $exists: does the row exist at all (without policy filter)?
281-
const existsInner = this.eb
282-
.selectFrom(mutationModel)
281+
const rowExistsInner = this.eb
282+
.selectFrom(model)
283283
.select(this.eb.lit(1).as('_'))
284-
.where(() => new ExpressionWrapper(userWhere));
284+
.where(() => new ExpressionWrapper(whereCondition));
285285

286-
const selections: SelectionNode[] = [
287-
SelectionNode.create(
288-
AliasNode.create(this.eb.exists(existsInner).toOperationNode(), IdentifierNode.create('$exists')),
289-
),
290-
];
286+
const fetchCodes = this.options.fetchPolicyCodes !== false;
287+
const selectedPolicies = fetchCodes ? codedPolicies : [];
291288

292-
// one EXISTS column per coded policy, folded into the same query
293-
for (const [i, policy] of codedPolicies.entries()) {
294-
const condition = this.compilePolicyCondition(mutationModel, undefined, operation, policy);
295-
const existsCondition = policy.kind === 'allow' ? logicalNot(this.dialect, condition) : condition;
289+
const codeSelections = selectedPolicies.map((policy, i) => {
290+
const condition = this.compilePolicyCondition(model, undefined, operation, policy);
291+
const violationCondition = policy.kind === 'allow' ? logicalNot(this.dialect, condition) : condition;
296292
const inner = this.eb
297-
.selectFrom(mutationModel)
293+
.selectFrom(model)
298294
.select(this.eb.lit(1).as('_'))
299-
.where(() => new ExpressionWrapper(conjunction(this.dialect, [userWhere, existsCondition])));
300-
selections.push(
295+
.where(() => new ExpressionWrapper(conjunction(this.dialect, [whereCondition, violationCondition])));
296+
return SelectionNode.create(
297+
AliasNode.create(this.eb.exists(inner).toOperationNode(), IdentifierNode.create(`$c${i}`)),
298+
);
299+
});
300+
301+
const result = await proceed({
302+
kind: 'SelectQueryNode',
303+
selections: [
301304
SelectionNode.create(
302-
AliasNode.create(this.eb.exists(inner).toOperationNode(), IdentifierNode.create(`$c${i}`)),
305+
AliasNode.create(this.eb.exists(rowExistsInner).toOperationNode(), IdentifierNode.create('$exists')),
303306
),
304-
);
305-
}
307+
...codeSelections,
308+
],
309+
} satisfies SelectQueryNode);
306310

307-
const checkResult = await proceed({ kind: 'SelectQueryNode', selections } satisfies SelectQueryNode);
308-
const row = checkResult.rows[0] ?? {};
311+
const row = result.rows[0] ?? {};
312+
if (!row.$exists) return;
309313

310-
if (row.$exists) {
311-
const policyCodes =
312-
this.options.fetchPolicyCodes !== false
313-
? codedPolicies.filter((_, i) => row[`$c${i}`]).map((p) => p.code!)
314-
: undefined;
315-
throw createRejectedByPolicyError(mutationModel, RejectedByPolicyReason.NO_ACCESS, undefined, policyCodes);
316-
}
314+
const policyCodes = fetchCodes
315+
? selectedPolicies.filter((_, i) => row[`$c${i}`]).map((p) => p.code!)
316+
: undefined;
317+
318+
throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS, undefined, policyCodes);
317319
}
318320

319321
private async postUpdateCheck(

tests/e2e/orm/policy/crud/delete.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,24 @@ model Foo {
4848
await expect(db.$qb.deleteFrom('Foo').executeTakeFirst()).resolves.toMatchObject({ numDeletedRows: 1n });
4949
await expect(db.foo.count()).resolves.toBe(1);
5050
});
51+
52+
it('does not throw for nonexistent row', async () => {
53+
const db = await createPolicyTestClient(
54+
`
55+
model Foo {
56+
id Int @id
57+
x Int
58+
@@allow('create,read', true)
59+
@@allow('delete', x > 0)
60+
}
61+
`,
62+
);
63+
await db.foo.create({ data: { id: 1, x: 1 } });
64+
65+
// nonexistent row — row does not exist at all, so postModelLevelCheck must NOT throw
66+
await expect(db.$qb.deleteFrom('Foo').where('id', '=', 999).executeTakeFirst()).resolves.toMatchObject({
67+
numDeletedRows: 0n,
68+
});
69+
await expect(db.foo.count()).resolves.toBe(1);
70+
});
5171
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('Policy error code tests', () => {
1010
// │ allow rule fails (string code) │ ✓ │ ✓ │ ✓ │ ✓ │
1111
// │ constant deny (true condition) │ ✓ │ │ │ │
1212
// │ no errorCode on rule │ ✓ │ │ │ │
13+
// │ code opt-in: NotFound vs RejectedByPol. │ │ │ ✓ │ ✓ │
1314
// │ multiple deny rules fire │ ✓ │ ✓ │ │ │
1415
// │ deny + allow conflict │ ✓ │ ✓ │ │ │
1516
// │ multiple allow rules all fail │ ✓ │ ✓ │ │ │
@@ -72,6 +73,33 @@ describe('Policy error code tests', () => {
7273
await expect(db.foo.create({ data: { x: 0 } })).toBeRejectedByPolicy(undefined, []);
7374
});
7475

76+
// ── opt-in: adding a code changes error type from NotFound to RejectedByPolicy ──
77+
78+
it('blocked update/delete yields NotFound without code, RejectedByPolicy with code', async () => {
79+
const schema = (withCode: boolean) => `
80+
model Foo {
81+
id Int @id
82+
x Int
83+
@@allow('create,read', true)
84+
@@allow('update', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''})
85+
@@allow('delete', x > 0${withCode ? ", 'NEED_POSITIVE_X'" : ''})
86+
}
87+
`;
88+
// Without error code: the ORM sees 0 affected rows and raises NotFound
89+
const dbNoCode = await createPolicyTestClient(schema(false));
90+
await dbNoCode.foo.create({ data: { id: 1, x: 0 } });
91+
await expect(dbNoCode.foo.update({ where: { id: 1 }, data: { x: -1 } })).toBeRejectedNotFound();
92+
await expect(dbNoCode.foo.delete({ where: { id: 1 } })).toBeRejectedNotFound();
93+
94+
// With error code: the plugin detects the policy block and raises RejectedByPolicy
95+
const dbWithCode = await createPolicyTestClient(schema(true));
96+
await dbWithCode.foo.create({ data: { id: 1, x: 0 } });
97+
await expect(dbWithCode.foo.update({ where: { id: 1 }, data: { x: -1 } })).toBeRejectedByPolicy(undefined, [
98+
'NEED_POSITIVE_X',
99+
]);
100+
await expect(dbWithCode.foo.delete({ where: { id: 1 } })).toBeRejectedByPolicy(undefined, ['NEED_POSITIVE_X']);
101+
});
102+
75103
// ── post-update: single rule, single code ─────────────────────────────────
76104

77105
it('surfaces code from deny/allow rule on post-update violation', async () => {

tests/e2e/orm/policy/crud/update.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,27 @@ model Foo {
12081208
await expect(db.foo.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ x: 1 });
12091209
});
12101210

1211+
it('does not throw for nonexistent row', async () => {
1212+
const db = await createPolicyTestClient(
1213+
`
1214+
model Foo {
1215+
id Int @id
1216+
x Int
1217+
@@allow('create', true)
1218+
@@allow('update', x > 1)
1219+
@@allow('read', true)
1220+
}
1221+
`,
1222+
);
1223+
1224+
await db.foo.createMany({ data: [{ id: 1, x: 2 }] });
1225+
1226+
// nonexistent row — row does not exist at all, so postModelLevelCheck must NOT throw
1227+
await expect(
1228+
db.$qb.updateTable('Foo').set({ x: 5 }).where('id', '=', 999).executeTakeFirst(),
1229+
).resolves.toMatchObject({ numUpdatedRows: 0n });
1230+
});
1231+
12111232
it('works with insert on conflict do update', async () => {
12121233
const db = await createPolicyTestClient(
12131234
`

0 commit comments

Comments
 (0)