Description
An update whose data contains only an empty implicit-many-to-many set: [] (disconnect-all) resolves as success for a caller who is denied by the model's update policy. No rows are written (the policy appears to filter the join-row mutations to nothing), but the caller receives a normal success result instead of a policy rejection.
A non-empty set on the same relation is correctly rejected for the same caller, so the two shapes behave inconsistently.
Environment
@zenstackhq/orm 3.7.2, @zenstackhq/plugin-policy 3.7.2, @zenstackhq/cli 3.7.2
- PostgreSQL 15
- Observed through the policy-enhanced client in an integration test suite
Minimal schema
model Profile {
id Int @id @default(autoincrement())
name String
ownerId Int
owner User @relation(fields: [ownerId], references: [id])
tags Tag[]
@@allow('read', auth() != null)
@@allow('update', auth() == owner) // only the owner may update
}
model Tag {
id String @id
profiles Profile[]
@@allow('read', auth() != null)
@@allow('update', auth() != null) // required for implicit-M2M set/connect
}
Repro
With a policy-enhanced client whose auth user is not the profile's owner (i.e. fails Profile's update policy):
// 1. Non-empty set — correctly rejected:
await dbAsNonOwner.profile.update({
where: { id },
data: { tags: { set: [{ id: 'a' }] } },
}); // ✅ throws a policy rejection
// 2. Empty set — succeeds, mutates nothing:
await dbAsNonOwner.profile.update({
where: { id },
data: { tags: { set: [] } },
}); // ❌ resolves as success
// 3. Existing tag links survive (the "successful" disconnect-all wrote nothing):
// profile.tags is unchanged when read back with an elevated client.
Verified matrix (same behaviour on two different implicit M2M relations in our app):
data shape (denied caller) |
Result |
{ rel: { set: [{ id }] } } |
policy rejection ✅ |
{ rel: { set: [] } } |
success, no write ❌ |
| any scalar field present alongside |
policy rejection ✅ |
Expected
The empty set: [] should be rejected for a caller who fails the model's update policy, consistent with the non-empty case — or at minimum should not report success. The operation semantically requests "disconnect all", so a denied caller getting ok is a misleading success signal: server actions that map policy rejections to error envelopes (inline form errors, API 403s) silently return success instead.
Workaround
Force any scalar column into the update payload (we use an explicit updatedAt: new Date() bump); the model's update policy is then evaluated and the caller gets a real rejection.
Happy to put together a runnable reproduction repo if that helps.
Description
An
updatewhosedatacontains only an empty implicit-many-to-manyset: [](disconnect-all) resolves as success for a caller who is denied by the model'supdatepolicy. No rows are written (the policy appears to filter the join-row mutations to nothing), but the caller receives a normal success result instead of a policy rejection.A non-empty
seton the same relation is correctly rejected for the same caller, so the two shapes behave inconsistently.Environment
@zenstackhq/orm3.7.2,@zenstackhq/plugin-policy3.7.2,@zenstackhq/cli3.7.2Minimal schema
Repro
With a policy-enhanced client whose auth user is not the profile's owner (i.e. fails
Profile'supdatepolicy):Verified matrix (same behaviour on two different implicit M2M relations in our app):
datashape (denied caller){ rel: { set: [{ id }] } }{ rel: { set: [] } }Expected
The empty
set: []should be rejected for a caller who fails the model'supdatepolicy, consistent with the non-empty case — or at minimum should not report success. The operation semantically requests "disconnect all", so a denied caller gettingokis a misleading success signal: server actions that map policy rejections to error envelopes (inline form errors, API 403s) silently return success instead.Workaround
Force any scalar column into the update payload (we use an explicit
updatedAt: new Date()bump); the model'supdatepolicy is then evaluated and the caller gets a real rejection.Happy to put together a runnable reproduction repo if that helps.