Understanding Access Policies
Proper authorization is the key to a secure application. ZenStack allows you to define access policies directly inside your data model, so it's easier to keep the policies in sync when your data models evolve.
This document introduces the access policy enforcement behavior regarding different database operations. The general principle is to make the behavior a natural extension of how Prisma behaves today.
Access policies are only effective when you call Prisma methods enhanced with enhance
or withPolicy
.
General rules​
Field-level access policies are in preview stage and its behavior may change in the future. Please let us know your feedback!
Access policies are expressed with the @@allow
/@@deny
model attributes or @allow
/@deny
field attributes. The attributes take two parameters. The first is the operation: create/read/update/delete (only read/update for field-level policies). You can use a comma-separated string to pass multiple operations or use 'all' to abbreviate all operations. The second parameter is a boolean expression indicating if the rule should be activated.
attribute @@allow(_ operation: String, _ condition: Boolean)
attribute @allow(_ operation: String, _ condition: Boolean)
attribute @@deny(_ operation: String, _ condition: Boolean)
attribute @deny(_ operation: String, _ condition: Boolean)
@@allow
/@allow
opens up permissions while @@deny
/@deny
turns them off. You can use them multiple times and combine them in a model. Whether an operation is permitted is determined as follows:
For model-level policies:
- If any
@@deny
rule evaluates to true, it's denied. - If any
@@allow
rule evaluates to true, it's allowed. - Otherwise, it's denied.
For field-level policies:
- If any
@deny
rule evaluates to true, it's denied. - If there exists
@allow
rules and at least one of them evaluates to true, it's allowed. - If there exists
@allow
rules but none of them evaluates to true, it's denied. - Otherwise, it's allowed.
Please note the difference between model-level and field-level rules. Model-level access are by-default denied, while field-level access are by-default allowed. E.g., if you don't specify any "read" rule for a model, the model is not readable. However, if you don't specify any "read" rule for a field, the field is readable.
Accessing user data​
When using enhance
to wrap a Prisma client for authorization, you pass in a context object containing the data about the current user (verified by authentication). This user
data can be accessed with the special auth()
function in access policy rules. Note that auth()
function's return value is typed as the User
model in your schema, so only fields defined in the User
model are accessible.
You can access its field to implement RBAC like:
model Post {
// full access for admins
// "role" field must be defined in the "User" model
@@allow('all', auth().role == 'Admin')
}
, or you can use it to check the user's identity directly.
model Post {
...
author User @relation(fields: [authorId], references: [id])
@@allow('all', author == auth())
}
Operations​
Create​
Prisma methods​
create
, createMany
, upsert
Behavior​
"Create" operation is governed by the create rules. Since an entity doesn't exist before it's created, the fields used in such rules implicitly refer to the creation result. Field validation is also considered a part of create rules.
When a create operation is rejected, a PrismaClientKnownRequestError
is thrown with code P2004
.
"Create" operations can contain nested creates, and if a nested-created model has create rules, they're also enforced. The entire "create" happens in a transaction.
Read​
Prisma methods​
findUnique
, findUniqueOrThrow
, findFirst
, findFirstOrThrow
, findMany
Behavior​
"Read" operations are filtered by read rules. For findMany
, entities failing policy checks are silently dropped. For findUnique
and findFirst
, null
is returned if the requested entity exists but fails policy checks. For findUniqueOrThrow
and findFirstOrThrow
, an error is thrown if the requested entity exists but fails policy checks.
model Foo {
id String @id
value Int
@@allow('read', value > 0)
}
// given there's a single Foo { id: "1", value: 0 } in the database
db.foo.findUnique({ where: { id: '1' } }); // => null
db.foo.findUniqueOrThrow({ where: { id: '1' } }); // => throws
db.foo.findFirst(); // => null
db.foo.findFirstOrThrow(); // => throws
db.foo.findMany(); // => []
The read rules also determine if the result of a mutation - create, update or delete can be read back. Therefore, even if a mutation succeeds (and is persisted), the call can still result in a PrismaClientKnownRequestError
because the entity being returned doesn't satisfy read rules.
model Foo {
id String @id
value Int
@@allow('read', value > 0)
}
// an entity is created in the database, but the call eventually throws because the result cannot be read back
const created = await db.foo.create({ data: { value: 0 } });
Field-level read rules don't affect the readability of model entities as a whole, however the annotated field is omitted from the result if it fails the checks.
model Foo {
id String @id
value Int @allow('read', value > 0)
}
// given there's a single Foo { id: "1", value: 0 } in the database
db.foo.findUnique({ where: { id: '1' } }); // => { id: '1' }
Update​
Prisma methods​
update
, updateMany
, upsert
Behavior​
"Update" operations are governed by the update rules. An entity has a "pre-update" and "post-update" state. Fields used in update rules implicitly refer to the "pre-update" state, and you can use the future()
function to refer to the "post-update" state. Field validation is also considered a part of update rules.
model Post {
id String @id
title String @length(1, 100)
author User @relation(fields: [authorId], references: [id])
authorId String
// "author" refers to "pre-update" and "future().author" refers to "post-update"
@@allow('update', author == auth() && future().author == author)
}
For top-level or nested updateMany
, access policies are used to "trim" the scope of the update (by merging with the "where" clause provided by the user). This can end up with fewer entities being updated than without policies. For unique update, either with a top-level update
or a nested update
to "to-one" relation, the update will be rejected if it fails policy checks. When an update operation is rejected, a PrismaClientKnownRequestError
is thrown with code P2004
.
model Foo {
id String @id
value Int
@@allow('create,read', true)
@@allow('update', value > 0)
}
// create a Foo { id: '1', value: 0 }
await db.foo.create({ data: { id: '1', value: 0 } });
// succeeds without updating anything
await db.foo.updateMany({ data: { value: 1 } }); // => { count: 0 }
// throws
await db.foo.update({ where: { id: '1' }, data: { value: 1 } });
Update operations can contain nested writes - creates, updates or deletes, and if a nested-written model has corresponding rules, they're also enforced. The entire update happens in a transaction.
model User {
id String @id
email String
profile Profile?
@@allow('all', true)
}
model Profile {
id String @id
user User @relation(fields: [userId], references: [id])
userId String
age Int
@@allow('update', future().age > 0)
}
// throws because the nested update of "Profile" fails policy
// neither the "User" nor the "Profile" is updated
await db.user.update({
where: { id: '1' },
data: {
email: 'abc@xyz.com',
profile: {
update: {
age: 0,
},
},
},
});
The handling of field-level update rules is the same as model-level ones. The only difference is that the rules are only activated when the annotated field is part of the update operation. In another word, when a field is set to be updated, its update rules are merged with model-level rules.
Delete​
Prisma methods​
delete
, deleteMany
Behavior​
Delete operations are governed by the delete rules. Since an entity doesn't exist after it's deleted, the fields used in such rules implicitly refer to the "pre-delete" state.
For top-level or nested deleteMany
, access policies are used to "trim" the scope of delete (by merging with the "where" clause provided by the user). This can end up with fewer entities being deleted than without policies. For unique delete, either with top-level delete
or nested delete
to "to-one" relation, the deletion will be rejected if it fails policy checks.
Aggregation​
Prisma methods​
aggregate
, groupBy
, count
Behavior​
Aggregation operations are filtered by read rules. Entities failing policy checks are silently excluded from the data set used for computing the aggregation result.