Skip to main content
Version: 2.x

Working With Relations

In the previous chapters, we've learned how to write basic policy rules involving model fields and the current user. However, real-world applications usually have authorization requirements that need to access relations. Here're some examples:

  • A Todo is readable if its parent List is not private.
  • A Post is readable if the current user is a member of the Organization that the Post belongs to.

In this chapter, we'll learn how to write policy rules that involve relations.

*-to-one Relations

Accessing *-to-one relation is straightforward. You simply reference the field directly. You can further visit the relation's fields using the dot notation.

model List {
id Int
private Boolean
}

model Todo {
id Int
list List @relation(...)

// `list` references a to-one relation
@@allow('update', !list.private)
}

You can chain the dot notation to make deep traversal in the relation graph. There's no limit on how deep you can go.

*-to-many Relations

To-many relations are a bit more complicated because you're dealing with a list of objects instead of one. The authorization conditions written with to-many relations can be one of the following three forms:

  • Does any of the objects in the list satisfy the condition?
  • Does all of the objects in the list satisfy the condition?
  • Does none of the objects in the list satisfy the condition?

ZenStack provides a succinct syntax called "Collection Predicate Expression" for writing such rules:

Any:  relation?[condition]
All: relation![condition]
None: relation^[condition]

The relation part refers to a to-many relation field. The condition part is a boolean expression scoped to the type of relation. I.e., it can reference the member fields of relation without any qualification.

info

A collection predicate expression must start with a to-many relation field. You can't use a simple array field (like String[]) with it. In the next chapter you'll learn about helper functions that help you work with simple arrays.

🛠️ Adding Relation-Based Access Control

Let's continue working on the schema of our Todo app and add policies that control data access for users within the same space.

schema.zmodel
model Space {
...

// require login
@@deny('all', auth() == null)

// everyone can create a space
@@allow('create', true)

// users in the space can read the space
@@allow('read', members?[user == auth()])

// space admin can update and delete
@@allow('update,delete', members?[user == auth() && role == 'ADMIN'])
}

model SpaceUser {
...

// require login
@@deny('all', auth() == null)

// space owner can add any one
@@allow('create', space.owner == auth())

// space admin can add anyone but not himself
@@allow('create', auth() != user && space.members?[user == auth() && role == 'ADMIN'])

// space admin can update/delete
@@allow('update,delete', space.members?[user == auth() && role == 'ADMIN'])

// user can read members of spaces that he's a member of
@@allow('read', space.members?[user == auth()])
}

model User {
...

// everyone can sign up
@@allow('create', true)

// full access by oneself
@@allow('all', auth() == this)

// can be read by users sharing any space
@@allow('read', spaces?[space.members?[user == auth()]])
}

model List {
...

// require login
@@deny('all', auth() == null)

// can be read by space members if not private
@@allow('read', owner == auth() || (space.members?[user == auth()] && !private))

// when create, owner must be set to current user, and user must be in the space
@@allow('create,update', owner == auth() && space.members?[user == auth()])

// can be deleted by owner
@@allow('delete', owner == auth())
}

model Todo {
...

// require login
@@deny('all', auth() == null)

// owner has full access
@@allow('all', list.owner == auth())

// space members have full access if the parent List is not private
@@allow('all', list.space.members?[user == auth()] && !list.private)
}

Let's test it out. Rerun generation and start REPL:

npx zenstack generate
npx zenstack repl

Switch to user Joey and fetch Todo Lists:

.auth { id: 1 }
db.list.findMany()
[
{
id: 1,
createdAt: 2023-11-08T04:38:53.385Z,
updatedAt: 2023-11-08T04:38:53.385Z,
spaceId: 1,
ownerId: 2,
title: 'Grocery',
private: false
}
]

We queried with user Joey and now can get the List created by Rachel because Joey is a member of the same space.

A note about "create" rules

You might have noticed we have two "create" rules in the SpaceUser model.

// space owner can add any one
@@allow('create', space.owner == auth())

// space admin can add anyone but not himself
@@allow('create', auth() != user
&& space.members?[user == auth() && role == 'ADMIN'])

One might be tempting to use one simple rule like the following to allow space admins to create new memberships.

@@allow('create', space.members?[user == auth() && role == 'ADMIN'])

However, it'll be problematic due to "create" rule's semantic - if an entity were to be created, would it satisfy the rules? Or, in more details, a create process works like the following:

  1. Initiate a transaction and create the entity.
  2. In the same transaction, try to read the created entity with the "create" rules as filter, and see if it succeeds.
  3. If the read fails, the transaction is rolled back; otherwise it's committed.

The "post-create check" semantic allows the rules to access relations of the entity being created. For simple cases, ZenStack may apply optimizations to reject a create request without initiating a transaction, but generally speaking the "post-create check" semantic is the correct way to think about it. We may introduce a "pre-create" policy type in the future.

With these in mind, if we were to use the simple rule, a user can add himself to any space as "ADMIN", because after the create happens, the new membership would satisfy the rule. By splitting the rule into two, we can prevent this from happening:

  • Space owner can add anyone (including himself) into the space.
  • Space admin can add anyone but not himself.
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