Skip to content

$unuseAll() / $unuse('policy') inside $transaction still apply PolicyPlugin #2906

Open

Description

@caiotarifa

Description and expected behavior

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)
}
const db = new ZenStackClient(schema, { dialect })
  .$use(new PolicyPlugin())
  .$setAuth({ id: 'u1', organizationId: 'o2' })

// table has one row: { id: 'o1', name: 'O1' }

const outside = await db.$unuseAll().organization.count()
const inside = await db.$transaction(tx => tx.$unuseAll().organization.count())
const unuse = await db.$transaction(tx => tx.$unuse('policy').organization.count())

console.log({ outside, inside, unuse })
// actual:   { outside: 1, inside: 0, unuse: 0 }
// expected: { outside: 1, inside: 1, unuse: 1 }

Environment:

  • ZenStack version: 3.10.0 (the code below is unchanged on main)
  • Database type: PostgreSQL 17
  • Node.js version: 24.x
  • Package manager: npm

Additional context

Looks like a side effect of #2497 (the fix for #2494). In the ClientImpl constructor:

if (baseClient?.isTransaction && !executor) {
    this.kysely = baseClient.$qb;
}

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.

Activity

  1. aliassar commented on Nov 4, 2026

    @aliassar

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions