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
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@@delegatesub-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 withcolumn "<basefield>" does not exist.The cross-tenant
dealership.UserAccesssub-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 ofPolicyHandler.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/orm3.5.6 (also reproduces on 3.5.5)kyselydialectMinimal reproducer
Now issue a legitimate update:
Expected
Update proceeds.
statusis read from the baseTransactiontable for policy evaluation (either via JOIN or via a separate SELECT targetingTransaction).Actual
Query fails with:
Emitted SQL (simplified):
Stack trace:
Possible root cause
Likely the same class of issue as #2588 fixed by #2591 (cursor filter), which resolved
fieldDef.originModelto route reads to the correct table.PolicyHandler.loadBeforeUpdateEntitiesappears to not consultoriginModelwhen 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
originModelresolution (same class of bug, different path)