Permissions
Keel takes a secure by default approach, which means that unless you explicitly define permission rules all your actions will return permission denied errors. Keel supports both row-based and role-based permission rules at the model or individual action level.
A role-based permission rule allows anyone who has one of the given role to perform any of the specified action types.
model MyModel {
@permission(
roles: [Staff],
actions: [get, create, update, delete]
)
}
A row-based permission rule is based on the data being acted on and uses an expression to determine if the action is allowed.
model MyModel {
@permission(
expression: post.author.identity == ctx.identity,
actions: [update]
)
}
@permission
attribute
The @permission
attribute can be used at model or action level and can be used to define row-based or role-based permission rules. It accepts three arguments: actions, expression, and roles.
Here's an example of model level permission rules:
model Post {
fields {
author Author
}
// Permissions for each row on the whole model
@permission(
expression: post.author.identity == ctx.identity,
actions: [update]
)
}
Actions
The actions
argument must be provided when the rule is defined at the model level, and is a list of action types that the rule should apply to. For action level permission rules this argument must be omitted.
Here's an example of action level permission rules:
model Post {
fields {
author Author
}
actions {
update updatePost(id) with (title)
// Permission nested inside the update action
@permission(
expression: post.author.identity == ctx.identity
)
}
}
Expressions
The expression
argument is for defining row-based permissions, and the provided expression is evaluated against each record being acted on. An expression is boolean. For example, here's an expression that checks if the authenticated user is the author of a post:
model Post {
fields {
author Author
}
@permission(
expression: post.author.identity == ctx.identity,
actions: [update]
)
}
Similarly, for a list
action the expression is evaluated for each record in the list. For example, the following schema only allows users to list posts that they are the author of:
model Post {
fields {
author Author
}
@permission(
expression: post.author.identity == ctx.identity,
actions: [list]
)
}
Some key things to understand are:
- There can be many permission rules in a model or action
- Action-level rules override model-level rules
- If any permission rule passes, the action is allowed
If provided the expression
argument has the following in-scope identifiers:
- the record, available as the lowerCamelCase version of the model name
ctx
- contains things like the authenticated identity, environment variables & secrets- any
enum
defined in your schema
If the expression evaluates to true for all records being acted on then the rule passes.
Roles
The roles
argument is for role-based permissions and lets you define a list of role names that the rule should allow. The caller must have one of the provided roles.
Roles are defined in your schema and are applied to the authenticated Identity based on either a specific email address or an email domain name. For example the following schema says that anyone with a @mycorp.com
email address has the Staff
role but only bob@mycorp.com
and sue@mycorp.com
have the Developer
role.
role Staff {
domains {
"mycorp.com"
}
}
role Developer {
emails {
"bob@mycorp.com"
"sue@mycorp.com"
}
}
You can then use these role names in permission rules. As an example, the following schema allows anyone with the Staff
role to update an order:
model Order {
fields {
status Text
}
actions {
update updateOrder(id) with (status)
}
@permission(
roles: [Staff],
actions: [update]
)
}
Public access
You can make a permission rule that will always pass by using the expression true
. The following schema allows public access to all actions in the Post
model:
model Post {
actions {
get getPost(id)
list listPosts(createdAt)
create newPost() with (title) @function
}
@permission(
expression: true,
actions: [get, list, create]
)
}
You can use the same approach to make a specific action public. For example, the following schema makes the getPost
action public:
model Post {
actions {
get getPost(id) {
@permission(expression: true)
}
}
}
Using Identity
A common permissions pattern is to only allow the owner/creator of some data to modify it. The following schema lets any authenticated user create posts, but only the author of a post can edit or publish a post.
model Post {
fields {
body Text
publishedAt Timestamp?
author Identity
}
actions {
create createPost() with (body) {
// set the author to the authenticated Identity
@set(post.author = ctx.identity)
}
update editPost(id) with (body)
update publishPost(id) {
@set(post.publishedAt = ctx.now)
}
}
// Any authenticated user can create
@permission(
expression: ctx.isAuthenticated,
actions: [create]
)
// Only the author of a post can update
@permission(
expression: post.author == ctx.identity,
actions: [update],
)
}
Relationships
You'll generally only add an Identity
field to the model that represents users in your app, which means that you'll often write permission rules that need to traverse your data model. Luckily this is very easy to do with expressions.
Imagine you're building a project management app which has users, projects, and tasks. Users can create projects and tasks can be added to projects. You only want the owner of a project to be able to update a task. To do this we just need to write a permission rule in the Task
model that joins to the User
and checks the identity
field against the authenticated Identity calling the action.
model User {
fields {
identity Identity @unique
}
}
model Project {
fields {
user User
}
}
model Task {
fields {
project Project
}
@permission(
expression: task.project.user.identity == ctx.identity,
actions: [update]
)
}
Many-to-many Relationships
In the previous example we saw how we can implement permission rules based on the owner (or creator) of some data, but what if we want to allow many people permission to edit some data. To illustrate this let's extend our project management app to allow for collaboration between users. Now many users can access a project and anyone with access to a project can create tasks in it.
model User {
fields {
identity Identity
projects UserProject[]
}
}
model Project {
fields {
users UserProject[]
}
}
model UserProject {
fields {
user User
project Project
}
}
model Task {
fields {
project Project
}
// allow any user with access to the project to create a Task
@permission(
expression: ctx.identity in task.project.users.user.identity,
actions: [create]
)
}
This schema has a many-to-many relationship between users and projects, represented by the UserProject
model, and so the expression task.project.users.user.identity
does the following:
- Starting at the
Task
record (in this case the one being created), go to the parentProject
- From the
Project
go to the list ofUserProject
's - From each
UserProject
go to theUser
- From each
User
get theidentity
field
The important thing here is the “for each” part of the last two steps. Because a project has many users we ultimately end up with a list of Identities. This means we can't compare ctx.identity
to task.project.users.user.identity
with ==
as they are different types. For this case we need to use the in
operator to say that the authenticated Identity must be in the list of Identity's with access to the project.
Functions
Permission rules apply to both built-in actions and functions, and you can add permission rules to specific functions too. The only exception to this is for read
and write
functions - schema defined permission rules do not apply to these functions and so you must implement any permissions logic in your code. See the docs on custom functions for more info.