Skip to content

Commit 885a04c

Browse files
Azzerty23claude
andcommitted
fix(policy): bypass read-policy hooks on internal pre-load queries for 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>
1 parent 84a698f commit 885a04c

4 files changed

Lines changed: 122 additions & 96 deletions

File tree

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from '../../query-utils';
5252
import { getCrudDialect } from '../dialects';
5353
import type { BaseCrudDialect } from '../dialects/base-dialect';
54+
import { internalQueryContextStorage } from '../../executor/internal-context';
5455
import { InputValidator } from '../validator';
5556

5657
/**
@@ -1211,18 +1212,24 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
12111212
return loadThisEntity();
12121213
}
12131214

1214-
if (
1215+
if (modelDef.baseModel) {
12151216
// when updating a model with delegate base, base fields may be referenced in the filter,
1216-
// so we read the id out if the filter and and use it as the update filter instead
1217-
modelDef.baseModel ||
1218-
// for dialects that don't support RETURNING, we need to read the id fields
1219-
// to identify the updated entity for toplevel updates
1220-
(!this.dialect.supportsReturning && !fromRelation)
1221-
) {
1222-
// update the filter to db-loaded id fields
1217+
// so we read the id out of the filter and use it as the update filter instead
12231218
combinedWhere = await loadThisEntity();
12241219
if (!combinedWhere) {
1225-
// not found
1220+
return null;
1221+
}
1222+
} else if (!this.dialect.supportsReturning && !fromRelation) {
1223+
// For dialects without RETURNING (e.g. MySQL) we must pre-load the entity's id fields
1224+
// so we can re-read the row after the UPDATE. This pre-load is internal bookkeeping —
1225+
// not a user-visible read — so it must bypass onKyselyQuery plugin hooks. Without the
1226+
// bypass, a read-policy denial would surface as "Record not found" here before the
1227+
// UPDATE runs, preventing the policy plugin from emitting the correct error code.
1228+
combinedWhere = await internalQueryContextStorage.run(
1229+
{ bypassOnKyselyHooks: true },
1230+
() => loadThisEntity(),
1231+
);
1232+
if (!combinedWhere) {
12261233
return null;
12271234
}
12281235
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
2+
3+
type InternalQueryContext = {
4+
/**
5+
* When true, `ZenStackQueryExecutor` skips all `onKyselyQuery` plugin hooks.
6+
* Used for internal pre-load queries that must not be filtered by access policies.
7+
*/
8+
bypassOnKyselyHooks?: boolean;
9+
};
10+
11+
export const internalQueryContextStorage = new AsyncLocalStorage<InternalQueryContext>();

packages/orm/src/client/executor/zenstack-query-executor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { BaseCrudDialect } from '../crud/dialects/base-dialect';
3838
import { createDBQueryError, createInternalError, ORMError } from '../errors';
3939
import type { AfterEntityMutationCallback, OnKyselyQueryCallback } from '../plugin';
4040
import { requireIdFields, stripAlias } from '../query-utils';
41+
import { internalQueryContextStorage } from './internal-context';
4142
import { QueryNameMapper } from './name-mapper';
4243
import { TempAliasTransformer } from './temp-alias-transformer';
4344
import type { ZenStackDriver } from './zenstack-driver';
@@ -197,6 +198,13 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor {
197198
) {
198199
let proceed = (q: RootOperationNode) => this.proceedQuery(connection, q, parameters, queryId);
199200

201+
// Internal pre-load queries (e.g. entity-ID fetch before an UPDATE on dialects without
202+
// RETURNING) must not be filtered by access-policy plugins, otherwise a row denied by
203+
// read-policy would surface as "Record not found" instead of the correct policy error.
204+
if (internalQueryContextStorage.getStore()?.bypassOnKyselyHooks) {
205+
return proceed(queryNode);
206+
}
207+
200208
const hooks: OnKyselyQueryCallback<SchemaDef>[] = [];
201209
// tsc perf
202210
for (const plugin of this.client.$options.plugins ?? []) {

0 commit comments

Comments
 (0)