Skip to content

v3 policy: update with only an empty implicit-M2M set: [] succeeds (silent no-op) for a caller denied by the model's update policy #2752

Description

@twobyte

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.

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