Model-Level Policies
The most frequently used access policies are those declared at the model level - written using @@allow
and @@deny
model-level attributes.
ZenStack follows Prisma's convention: model-level attributes are prefixed with @@
, and field-level attributes with @
.
Here's a basic example:
model Post {
id Int @id
title String
published Boolean @default(false)
// published posts are readable
@@allow('read', published)
}
You can also achieve the same goal with @@deny
:
model Post {
id Int @id
title String
published Boolean @default(false)
@@allow('read', true)
@@deny('read', !published)
}
Both @@allow
and @@deny
take two arguments:
-
Operation
create
,read
,update
,delete
, or a comma-separated list of them. You can also useall
to abbreviate all operations. -
Condition: a boolean expression
You can use boolean literals:
true
andfalse
. Operators==
,!=
,>
,>=
,<
, and<=
can be used to compare values. You can also use&&
,||
, and!
to compose boolean expressions.There are functions and special expressions that help you write more advanced conditions. We'll cover them in later chapters.
Evaluation of Model-Level Policies
You can write as many policy rules as you want for a model. The order of the rules doesn't matter.
ZenStack determines whether a CRUD operation is allowed using the following logic:
- If any
@@deny
rule evaluates to true, it's denied. - If any
@@allow
rule evaluates to true, it's allowed. - Otherwise, it's denied (secure by default).
Each of the CRUD operation types governs a set of Prisma Client methods, as follows:
-
create
create
,createMany
,upsert
, andcreate
/createMany
/connectOrCreate
nested in create/update calls. -
read
findUnique
,findUniqueOrThrow
,findFirst
,findFirstOrThrow
,count
,aggregate
, andgroupBy
.The "read" operation also determines whether the value returned from
create
,update
anddelete
method can be read. -
update
update
,updateMany
,upsert
, andupdate
/updateMany
/set
/connect
/connectOrCreate
/disconnect
nested in create/update calls. -
delete
delete
,deleteMany
, anddelete
nested in update calls.
When a Prisma call involves relation manipulation, it can be unobvious which side the relation needs to satisfy the "update" policies. For example, given the following model:
model User {
...
posts Post[]
}
model Post {
...
author User @relation(fields: [authorId], references: [id])
authorId Int
}
If we add a post to a user like:
db.user.update({
where: { id: 1 },
data: {
posts: { connect: { id: 1 } }
}
})
Does Post#1
need to satisfy its "update" policies?
The trick is to consider which side of the relation will have a foreign key update. In this example, the foreign key authorId
of Post#1
will be updated, so Post#1
needs to satisfy its "update" policies, and User
's "update" policies are not relevant.
A special case is Prisma's implicit many-to-many relation. Since there are no explicit foreign keys in this case, manipulating such a relation requires both sides to satisfy the "update" policies.
How Do Policies Affect Prisma Client's Behavior?
Enforcing access policies causes an enhanced Prisma Client to behave differently from the original. You can predict what happens with the following principles:
-
Read methods behave as if the rows not satisfying the policies don't exist
For example,
findMany
only returns rows that satisfy the policies.count
only counts rows that satisfy the policies. This applies to nested reads as well. ZenStack enforces "read" policies by injectingwhere
clauses into the Prisma query.
There's one thing that may catch you off guard. When you do a find and include a to-one relation field, if the field is not nullable and cannot be ready by the current user, it'll result in the parent record being filtered out. For example:
// if `author` is not readable, the parent `Post` is excluded
const posts = await db.post.findMany({ include: { author: true }});
The reason for this design choice is two-fold:
-
Type safety
If we silently drop the
author
field, we'll break type-safety because theauthor
field is not optional in its TS definition. -
Performance
Prisma doesn't provide a direct way to include a non-nullable to-one relation conditionally. If we choose to drop the field, we'll have to do post-read processing, which may require additional database queries.
-
Bulk update and bulk delete methods behave as if the rows that don't satisfy the policies don't exist
For example,
updateMany
only updates rows that satisfy the policies. Same fordeleteMany
. ZenStack enforces "update" and "delete" policies by injectingwhere
clauses into the Prisma query. -
Other write methods throw errors if the corresponding policies are not satisfied
For example,
create
throws an error if the policies are not satisfied. When possible, ZenStack determines policy satisfaction by inspecting the input object of the Prisma query. Otherwise, it wraps the write into a transaction and checks the policies after the write is complete but before the transaction commits.If a nested write causes a policy violation, the top-level write will be rejected as a whole.
Here's another fun fact about access policies. A create
, update
or delete
call may succeed but still throw an error. Why?
The reason is that a write can imply a read. When you call db.user.create
, the created result will be returned and subject to the "read" policy check. If it fails, an error will be thrown even though the write is persisted.
🛠️ Giving It a Try
Let's continue working on our Todo app and add some access policies. Add the following rule to the User
model:
model User {
...
@@allow('read', startsWith(email, 'joey'))
}
startsWith
is an attribute function that checks whether a field starts with a given string. We'll cover functions in detail in Expressions and Functions.
Then rerun generation and start the REPL:
npx zenstack generate
npx zenstack repl
Now query users with the enhanced Prisma Client:
db.user.findMany();
This time, we should get back a user. Our policy rule is working!
[
{
id: 1,
createdAt: 2023-11-07T21:37:22.506Z,
updatedAt: 2023-11-07T21:37:22.506Z,
email: 'joey@zenstack.dev',
name: 'Joey'
}
]
You'll still encounter an error if you try to create a user since we haven't added any "create" rule yet:
db.user.create({ data: { email: 'ross@zenstack.dev', name: 'Ross' } });
denied by policy: user entities failed 'create' check
Code: P2004
Meta: { reason: 'ACCESS_POLICY_VIOLATION' }
Next
Using only the direct fields of a model for access control isn't super useful. In the following chapters, you'll learn how to the requesting user's information and relation fields to write more advanced rules.