Skip to main content
Version: 2.x

Field-Level Policies

In the previous parts, we've introduced how to write model-level policies to control CRUD permissions. In database terms, this is called row-level security. With the power of relation traversal, current user access, and flexible expressions and functions, you are fully equipped to handle most real-world authorization scenarios. Sometimes, however, you'll find yourself needing more fine-grained access control.

ZenStack's field-level policies allow you to define access rules for individual fields. For example, you can allow a blog post's owner to update its title and content, but only users with the "EDITOR" role can change the "published" field.

The combination of model-level and field-level policies gives you the ultimate granularity and flexibility and lets ZenStack surpass Postgres's native row-level-security capabilities.

Defining Field-Level Policies

To define field-level policies, you use the @allow and @deny field-level attributes to attach rules to fields. Beware that field-level attributes always start with a single @. Here's an example:

model Post {
published Boolean @allow('update', auth().role == EDITOR)

You can't use future() function in field-level access policies. To express post-update rules, put them into model-level policies. We have an active feature request for it.

Difference Between Field-Level and Model-Level Policies

A few key differences between field-level policies and model-level policies:

  • Field-level policies only support "read", "update", and "all" operations. It's not meaningful to control "create" and "delete" permissions at the field level.
  • Field-level access is allowed by default. If you don't attach any rule to a field, it's accessible as long as the model is accessible. On the contrary, model-level access is denied by default. You'll have to explicitly open up access using @@allow attributes.

Behavior of Field-Level Access Control

A field's accessibility is determined in the following order:

  • If no access policies are defined for the field, the operation is allowed
  • If any @deny rule is satisfied, the operation is denied
  • If some @allow rules are defined, and none of them are satisfied, the operation is denied
  • Otherwise, the operation is allowed

When a "read" operation is denied, the field is dropped from the result. When an "update" operation is denied, the operation is rejected with an error.

Overriding Model-Level Policies

To successfully "read" or "update" a field, the current user must first satisfy the corresponding model-level policies unless the field-level @allow attribute is passed with a third argument true to override.

model Post {
published Boolean @allow('update', auth().role == EDITOR, true)

@@allow('update', !published)

In the example above, if a Post is published, although the model-level policy denies updates, an EDITOR user can still update the published field (and only this field) because of the overriding setting on the field-level policy. Without the override flag, the update operation will be denied.

You can use this feature with "read" policies, too. If a field has an override "read" policy, the field can be read if it's explicitly selected in the query, even if the model-level policy denies access.

🛠️ Adding Field-Level Policies

Back to where we left off in the previous chapter, let's tighten up our schema and prevent the ownerId field from being updated for List and Todo:

model List {
ownerId Int @default(auth().id) @deny('update', true)

model Todo {
ownerId Int @default(auth().id) @deny('update', true)

Rerun generation and start REPL:

npx zenstack generate
npx zenstack repl

First, create a new List using Joey (id #1):

.auth { id: 1 }
db.list.create({ data: { title: "Joey' List", private: true, owner: { connect: { id: 1 } }, space: { connect: { id: 1 } } } })


id: 4,
createdAt: 2023-11-09T05:36:20.264Z,
updatedAt: 2023-11-09T05:36:20.264Z,
spaceId: 1,
ownerId: 1,
title: "Joey' List",
private: true

Try to update the owner field to Rachel (id #2):

db.list.update({ where: { id: 1 }, data: { owner: { connect: 2 } } })

The operation is denied:

denied by policy: list entities failed 'update' check, entity { id: 1 } failed update policy check for field "owner"
Code: P2004
Feel free to ask questions, give feedback, or report issues.

Don't Spam

You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below