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 } } } })
Result:
{
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
Meta: { reason: 'ACCESS_POLICY_VIOLATION' }