Accessing Current User
In most cases, whether a CRUD operation is allowed depends on the current user. ZenStack provides a special function auth()
to access the current user in policy rules.
Auth Model
When ZModel is compiled, the auth()
function needs to be resolved to a data model so that the compiler knows which fields are accessible from the function. By default, the model named "User" is used as the auth model. Here's an example:
model User {
id Int @id
role String
posts Post[]
}
model Post {
id Int @id
title String
author User @relation(fields: [authorId], references: [id])
authorId Int
// ✅ valid rule
@@allow('all', auth().role == 'ADMIN')
// ❌ invalid rule, `subscribed` field doesn't exist in `User` model
@@allow('all', auth().subscribed == true)
}
You can use a model not named User
as the auth model by adding the @@auth
attribute to it.
model MyUser {
id Int @id
role String
posts Post[]
@@auth
}
If you use an external authentication service like Clerk or Supabase Auth, you can choose not to store users in the database, since the auth services are already doing this for you. However, in ZModel, the auth()
function call always needs to be resolved to a typing.
The solution is to use a type
instead of a model
to define the shape of auth()
. A type
can contain fields as models do, the it's not mapped to a database table, and only serves the purpose of typing.
type User {
id String @id
...
}
Since User
is not mapped to a database table, you can't have other models to have relations to it. Instead, you can store the user id provided by the authentication service in your models, which is like a "foreign key" but pointing to an external system.
Providing Current User
Since ZenStack is not an authentication solution, it doesn't know who the current user is. It's up to the developer to get it from the authentication side and pass it to ZenStack (when calling the enhance
function).
Here's the pseudo-code:
// `getCurrentUser` is an authentication API that extracts
// the current user from the request
const user = await getCurrentUser(request);
// create an enhanced Prisma Client for the user, the `user` object
// provides value for the `auth()` function in policy rules
const db = enhance(prisma, { user })
The minimum requirement for the user
object is to have a value for the "id" field of the auth model (if the model uses compound id fields, all fields need to be assigned). However, if your access policies involve other fields of the auth model, you need to provide them as well.
ZenStack doesn't query the database automatically to fetch the missing fields, but you can do that as needed. Let's say if you have a policy rule like:
@@allow('update', auth().role == 'ADMIN')
Before calling enhance()
, you can hit the database (using the original Prisma Client) to fetch the user's role:
const userId = getCurrentUserId(request);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { id: true, role: true } });
const db = enhance(prisma, { user });
In the future, we may introduce a new option to control if the missing fields should be automatically fetched from the database.
Writing Conditions With auth()
Checking Anonymous User
You can indicate that the current user is anonymous by not passing the user
object (or passing undefined
) when calling enhance
:
const db = enhance(prisma);
In policy rules, check auth() == null
for anonymous user:
model Post {
...
// allow all login users to read
@@allow('read', auth() != null)
}
Comparing auth()
With Other Fields
You can compare auth()
against a field of the same type (i.e., the auth model). Such comparison is equivalent to an id field comparison. For example, the following policy rule:
model Post {
...
author User @relation(fields: [authorId], references: [id])
authorId Int
@@allow('update', auth() == author)
}
is equivalent to:
model Post {
...
author User @relation(fields: [authorId], references: [id])
authorId Int
@@allow('update', auth().id == author.id)
}
, and is also equivalent to:
model Post {
...
author User @relation(fields: [authorId], references: [id])
authorId Int
@@allow('update', auth().id == authorId)
}
Traversing Relation Fields
You can access auth model's relation fields from auth()
function and chain them with other fields. Remember ZenStack doesn't fetch the fields accessed from auth()
automatically, so you need to provide them (recursively if there's multi-level traversing) when calling enhance()
.
model User {
...
role Role
}
model Role {
...
permissions Permission[]
}
model Permission {
...
name String // READ, WRITE, etc.
}
model Post {
...
@@allow('read', auth().role.permissions?[name == 'READ'])
}
The expression?[condition]
syntax used above is called "Collection Predicate Expression". It's used for computing a boolean value from a "*-to-many" relation field. You'll learn more about it in the next chapter.
Using auth()
in @default()
Another very useful place to use auth()
is in the @default()
attribute. It's common to require a resource's owner to be the current user when creating an entity. You can use a "create" rule to enforce that:
model Resource {
...
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
@@allow('create', owner == auth())
}
The policy ensures that the caller can't set a resource's owner to someone else. However, when making a create call, you'll still need to connect the owner:
await db.resource.create({ data: { ..., owner: { connect: { id: currentUser.id } } } });
This can be simplified by using auth()
in the @default()
attribute:
model Resource {
...
owner User @relation(fields: [ownerId], references: [id])
ownerId Int @default(auth().id)
@@allow('create', owner == auth())
}
Now, when creating a resource, you don't need to explicitly make the owner connection.
🛠️ Adding User-Based Access Control
Add the following policies and default values to the schema:
model User {
...
// everyone can sign up
@@allow('create', true)
// full access by oneself
@@allow('all', auth() == this)
}
model Space {
...
ownerId Int @default(auth().id)
}
model List {
...
ownerId Int @default(auth().id)
// owner has full access
@@allow('all', auth() == owner)
}
model Todo {
...
ownerId Int @default(auth().id)
// owner and list owner has full access
@@allow('all', auth() == owner || auth() == list.owner)
}
Rerun generation and start REPL:
npx zenstack generate
npx zenstack repl
The REPL provides a ".auth" command for setting the current user. The user object passed to the command will serve value for the auth()
function in policy rules at runtime. Run ".auth" without arguments to switch back to the anonymous user.
Switch to user#1 and make a query:
.auth { id: 1 }
db.user.findMany();
[
{
id: 1,
createdAt: 2023-11-07T21:37:22.506Z,
updatedAt: 2023-11-07T21:37:22.506Z,
email: 'joey@zenstack.dev',
name: 'Joey'
}
]
Switch to user#2 and observe the difference:
.auth { id: 2 }
db.user.findMany();
[
{
id: 2,
createdAt: 2023-11-07T21:37:22.509Z,
updatedAt: 2023-11-07T21:37:22.509Z,
email: 'rachel@zenstack.dev',
name: 'Rachel'
}
]
We can also create Todo List now:
db.list.create({ data: { title: 'Grocery', space: { connect: { slug: 'central-perk' } } } })
{
id: 1,
createdAt: 2023-11-08T04:38:53.385Z,
updatedAt: 2023-11-08T04:38:53.385Z,
spaceId: 1,
ownerId: 2,
title: 'Grocery',
private: false
}
Notice that we don't need to connect the owner
relation when creating? The @default(auth().id)
attribute takes care of it.
However, as user Rachel, we can't create a Todo List on behalf of Joey because it violates the @@allow('all', auth() == owner)
rule:
db.list.create({ data: { title: 'Grocery', owner: { connect: { email: 'joey@zenstack.dev' } }, space: { connect: { slug: 'central-perk' } } } })
denied by policy: list entities failed 'create' check, entity { id: 2 } failed policy check
Code: P2004
Meta: { reason: 'ACCESS_POLICY_VIOLATION' }
Allowing only the owners to read and write their own data is a good start, but we would like to let users in the same Space
to collaborate on the Todo lists and items in the space. Achieving this requires traversing relations in policy rules, which we'll cover in the next chapter.