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.
Examples
Model with permission attributes
model Employee {
fields {
name Text
department Text
isActive Boolean
}
@permission(
roles: [Manager],
actions: [create, update, delete]
)
@permission(
expression: employee.department == "HR",
actions: [read]
)
}
In this example:
- Users with the
Manager
role can create, update, and delete employees. - Any user in the "HR" department can read employee data.
Action with permission attribute
model Document {
fields {
title Text
content Text
owner Identity
}
actions {
update updateDocument(id, content) {
@permission(
expression: ctx.identity == document.owner,
)
}
}
}
Here, the updateDocument
action can only be performed by the user who owns the document.
Permissions with complex expressions
@permission(
expression: (post.user.role == "Admin" or post.user.role == "Editor") and post.isPublished == false,
actions: [update, delete]
)
This permission allows users with the Admin
or Editor
role to update or delete items that are not yet published.
Examples
Model with fields, actions, and permissions
model Post {
fields {
title Text
content Markdown
author Identity
}
actions {
create createPost(title, content) {
@function
}
update updatePost(id, title, content) {
@function
@permission(
expression: ctx.identity == post.author,
actions: [update]
)
}
delete deletePost(id) {
@function
@permission(
roles: [Admin],
actions: [delete]
)
}
}
@permission(
actions: [read],
roles: [Viewer]
)
}
Role definitions
role Admin {
emails {
"admin@keel.xyz"
}
}
role Editor {
domains {
"keel.xyz"
}
}
role Viewer {
domains {
"public.keel.xyz"
}
}
Model with role-based permissions
model Procedure {
fields {
name Text @unique
}
actions {
get getProc(name)
create doProcedure() with (name) {
@permission(roles: [Surgeon])
}
}
}
role Surgeon {
domains {
"barts.org"
}
emails {
"sam.brainsurgeon@gmail.com"
"sally.heartsurgeon@gmail.com"
}
}
In this example:
- The
Procedure
model defines adoProcedure
action that is restricted to users with theSurgeon
role. - The
Surgeon
role is assigned to specific domains and emails.
Model with complex permission expressions
model Post {
fields {
title Text?
views Number?
identity Identity?
}
actions {
create create() with (title, views)
create createUsingRole() with (title) {
@permission(roles: [Poster])
}
get get(id)
update update(id) with (title)
delete delete(id)
}
@permission(
expression: post.title == "hello",
actions: [create, get, update]
)
@permission(
expression: post.views == 5,
actions: [get]
)
@permission(
expression: true,
actions: [delete]
)
}
role Poster {
domains {
"times.co.uk"
}
emails {
"chiefEditor@dailygazette.com"
"sportsWriter@athleticnews.net"
"techReporter@futuretech.io"
"foodCritic@culinaryreview.org"
"travelBlogger@wanderlust.com"
}
}
In this example:
- The
Post
model has multiple@permission
attributes with expressions based on field values. - The
createUsingRole
action is accessible only to users with thePoster
role. - The
Poster
role is defined with specific domains and emails.
Action with database field comparison in permissions
model Task {
fields {
title Text
project Project
}
actions {
get getTask(id)
}
@permission(
actions: [get],
expression: (
ctx.identity in task.project.users.user.identity and
"Admin" in task.project.users.role
)
)
}
model Project {
fields {
name Text
users ProjectUser[]
}
}
model ProjectUser {
fields {
user User
project Project
role Text
}
}
model User {
fields {
name Text
identity Identity
projects ProjectUser[]
}
}
In this example:
- The
Task
model'sgetTask
action is restricted by a complex@permission
expression. - The permission checks if the current user's identity is part of the task's project's users and whether they have the "Admin" role.
Using enums in permissions
enum PostType {
Technical
Lifestyle
Food
}
model Post {
fields {
title Text
type PostType
}
actions {
get getPost(id) {
@permission(expression: post.type == PostType.Technical)
}
}
}
In this example:
- The
Post
model uses an enumPostType
. - The
getPost
action is permitted only when the post's type isTechnical
.
Permission with identity comparison
model Post {
fields {
title Text
identity Identity
}
actions {
get getPost(id) {
@permission(expression: post.identity == ctx.identity)
}
}
}
In this example:
- Access to the
getPost
action is granted only if the post'sidentity
matches the current user's identity (ctx.identity
).
Jobs with permissions
job ManualJob {
inputs {
id ID
}
@permission(roles: [Admin])
}
job ManualJobTrueExpression {
inputs {
id ID
}
@permission(expression: true)
}
role Admin {
domains {
"keel.so"
}
}
In this example:
- The
ManualJob
job can only be executed by users with theAdmin
role. - The
ManualJobTrueExpression
job is allowed for all users due to theexpression: true
permission. Jobs are only accessible via the console (unlike actions which are accessed via API) so thetrue
expressions represents all users with project access.