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.

Example of role-based permission
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.

Example of row-based permission
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 parent Project
  • From the Project go to the list of UserProject's
  • From each UserProject go to the User
  • From each User get the identity 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.