Skip to content

@@delegate: loadBeforeUpdateEntities selects base-model fields against sub-model table — "column does not exist" #2595

@niehaus1301

Description

@niehaus1301

When a policy rule references a base-model scalar field (either directly on the base, via a field-level @deny/@allow, via a @@deny('post-update', before().<field> …), or via relation traversal from another model), and the write is dispatched through a concrete @@delegate sub-model client, the policy handler's pre-update loader emits the SELECT against the sub-model table instead of joining to the base table. Postgres then rejects with column "<basefield>" does not exist.

The cross-tenant dealership.UserAccess sub-conditions in the same query compile correctly — they route through (select "Transaction"."dealershipId" from "Transaction" where … = "Sub"."id"). So the compiler already knows how to reach the base row in some contexts. The bug is specifically in the SELECT-list projection of PolicyHandler.loadBeforeUpdateEntities (and very likely the equivalent delete/create loaders).

This is the same class of bug as #2588/#2591 (cursor filter) but on a different code path.

Environment

  • @zenstackhq/orm 3.5.6 (also reproduces on 3.5.5)
  • Postgres 16
  • Node 22
  • TypeScript 5.x, ESM
  • kysely dialect

Minimal reproducer

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client { provider = "prisma-client-js" }

model Dealership {
  id          String       @id @default(cuid())
  userAccess  UserAccess[]

  @@allow('create,read,update,delete', userAccess?[userId == auth().id])
}

model UserAccess {
  id           String     @id @default(cuid())
  userId       String
  dealershipId String
  dealership   Dealership @relation(fields: [dealershipId], references: [id])

  @@allow('create,read,update,delete', userId == auth().id)
}

model User {
  id String @id @default(cuid())
}

enum TxStatus { Draft Finalized }

// Base
model Transaction {
  id           String     @id @default(cuid())
  variant      String
  status       TxStatus   @default(Draft)
  dealershipId String
  dealership   Dealership @relation(fields: [dealershipId], references: [id])

  amountGross  Decimal  @deny('update', status == 'Finalized')

  @@delegate(variant)
  @@allow('create,read,update', dealership.userAccess?[userId == auth().id])
  @@allow('delete',               dealership.userAccess?[userId == auth().id] && status == 'Draft')
  @@deny('post-update', before().status == 'Finalized' && status == 'Draft')
}

// Sub-model
model Invoice extends Transaction {
  invoiceNumber String?
}

Now issue a legitimate update:

await db.invoice.update({
  where: { id },
  data: { invoiceNumber: 'INV-1' }
});

Expected

Update proceeds. status is read from the base Transaction table for policy evaluation (either via JOIN or via a separate SELECT targeting Transaction).

Actual

Query fails with:

error: column "status" does not exist

Emitted SQL (simplified):

SELECT "status", "id"
FROM "public"."Invoice"
WHERE "Invoice"."id" = $1
  AND <correctly-resolved dealership.userAccess subquery that DOES reach into Transaction>

Stack trace:

PolicyHandler.loadBeforeUpdateEntities (plugin-policy/src/policy-handler.ts:596)
PolicyHandler.handle (plugin-policy/src/policy-handler.ts:121)

Possible root cause

Likely the same class of issue as #2588 fixed by #2591 (cursor filter), which resolved fieldDef.originModel to route reads to the correct table. PolicyHandler.loadBeforeUpdateEntities appears to not consult originModel when building the SELECT projection for delegate sub-models, and the write-policy subquery builder does not consult it when emitting correlated subqueries against delegate relation targets.

Related issues for the assignee

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions