Skip to main content
Version: 2.x

Model-Level Policies

The most frequently used access policies are those declared at the model level - written using the @@allow and @@deny model-level attributes.

Model-level vs field-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:

  1. Operation

    create, read, update, delete, or a comma-separated list of them. You can also use all to abbreviate all operations.

  2. Condition: a boolean expression

    You can use boolean literals: true and false. 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:

  1. If any @@deny rule evaluates to true, it's denied.
  2. If any @@allow rule evaluates to true, it's allowed.
  3. 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, and create/createMany/connectOrCreate nested in create/update calls.

  • read

    findMany, findUnique, findUniqueOrThrow, findFirst, findFirstOrThrow, count, aggregate, and groupBy.

    The "read" operation also determines whether the value returned from create, update and delete method can be read.

  • update

    update, updateMany, upsert, and update/updateMany/set/connect/connectOrCreate/disconnect nested in create/update calls.

  • delete

    delete, deleteMany, and delete nested in update calls.

Relation manipulation and policies

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 injecting where clauses into the Prisma query.

Nested read can filter out parent records

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:

  1. Type safety

    If we silently drop the author field, we'll break type-safety because the author field is not optional in its TS definition.

  2. 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 for deleteMany. ZenStack enforces "update" and "delete" policies by injecting where 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.

A write can imply a read

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:

schema.zmodel
model User {
...

@@allow('read', startsWith(email, 'joey'))
}
info

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.

Comments
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