You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
$unuseAll() / $unuse('policy') inside $transaction still apply PolicyPlugin #2906
Inside an interactive transaction, a client derived with $unuseAll() or $unuse('policy') still enforces access policies. The same call outside a transaction bypasses them, as documented.
Expected: the derived client stays in the same transaction (as fixed in #2494) but runs without the removed plugins.
plugin policy {
provider = '@zenstackhq/plugin-policy'
}
type AuthUser {
id String @id
organizationId String
@@auth
}
model Organization {
id String @id
name String
@@deny('all', auth() == null || auth().organizationId != id)
@@allow('all', true)
}
The derived client reuses the transaction's Kysely instance, whose executor is the ZenStackQueryExecutor created for the original client. That executor still runs the original plugin list, so $unuse* has no effect on queries run through the derived client. Keeping the transaction is right; the plugins should come from the derived client.
Impact: code that needs a policy-free client inside a transaction (e.g. an onEntityMutation hook with runAfterMutationWithinTransaction: true, or a procedure called within $transaction) silently gets filtered results instead of an error.
I dug into this. A client derived inside a transaction reuses the transaction's Kysely instance (this.kysely = baseClient.$qb in client-impl.ts, from #2497), and that executor's ZenStackQueryExecutor still points at the original client, so onKyselyQuery hooks come from the original $options.plugins. That's why $unuse('policy') still applies the policy, and a plugin added with $use inside a transaction wouldn't get onKyselyQuery either.
I have a local fix where a derived tx client builds its own ZenStackQueryExecutor using the transaction's executor as its connection provider. Your issue repro and a regression test pass with it, as do the transaction, plugin-infra and client-extensions e2e tests on sqlite. One catch: Kysely's "already committed" guard lives in its internal wrapper, so a derived client kept around after the transaction ends would no longer throw and would run on the released connection.
Would you rather keep that guard through some shared transaction-liveness state passed to derived clients, or is the simpler version OK for a first PR? Happy to do either.
Activity
aliassar commentedon Nov 4, 2026
I dug into this. A client derived inside a transaction reuses the transaction's Kysely instance (
this.kysely = baseClient.$qbinclient-impl.ts, from #2497), and that executor'sZenStackQueryExecutorstill points at the original client, soonKyselyQueryhooks come from the original$options.plugins. That's why$unuse('policy')still applies the policy, and a plugin added with$useinside a transaction wouldn't getonKyselyQueryeither.I have a local fix where a derived tx client builds its own
ZenStackQueryExecutorusing the transaction's executor as its connection provider. Your issue repro and a regression test pass with it, as do the transaction, plugin-infra and client-extensions e2e tests on sqlite. One catch: Kysely's "already committed" guard lives in its internal wrapper, so a derived client kept around after the transaction ends would no longer throw and would run on the released connection.Would you rather keep that guard through some shared transaction-liveness state passed to derived clients, or is the simpler version OK for a first PR? Happy to do either.