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.

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 a doProcedure action that is restricted to users with the Surgeon 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 the Poster 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's getTask 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 enum PostType.
  • The getPost action is permitted only when the post's type is Technical.

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's identity 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 the Admin role.
  • The ManualJobTrueExpression job is allowed for all users due to the expression: true permission. Jobs are only accessible via the console (unlike actions which are accessed via API) so the true expressions represents all users with project access.