Want to learn SpiceDB? Take the intro course

User Defined Roles

/assets/team/jake-moshenko.jpg
October 27, 2021|11 min read

We’ve all interacted with applications and products that have a role editor that’s spiritually similar to the one above. As an administrator of the app, you get to define the names of the roles, and the capabilities that they imply. This allows you to set up the roles to match the roles at your company and federate out permissions to match. Often these applications will come with a default set of roles and permissions that match a pre-canned workflow, but allow you to change the workflow to fit your organization.

JIRA, for example, comes with a built-in role called admin, and lets you define roles for the various people who will be contributing to this particular productivity app. The example above was inspired by a far simpler version of an issue tracking tool which only has issues and comments on those issues.

In today’s post we will work through how to model this application in Authzed’s schema language, how to integrate it with your application, and how to direct that integration’s reads and writes to both an instance of our open-source implementation SpiceDB or to our hosted service Authzed.

Let’s get started by modeling the schema in the Authzed Playground!

Modeling the Schema

Modeling the User

The first thing we usually do when modeling any application in Authzed is create an object definition for users themselves:

definition user {}

This allows us to refer to users as subjects in test relationships with the following syntax:

user:claudia
user:really-long-user-id-maybe-a-uuid

Modeling Capabilities

Before we can start assigning capabilities to roles, we must first enumerate them! In Authzed we think about capabilities in terms of permissions. As a reminder, permissions are the external API of your permissions system, and they are the integration point with your application at enforcement points. For example, before we allow someone to create an issue within a project we probably want to check that the user has permission.

In pseudocode:

if authzed.check(resource="project:oursoftware", permission="create_issue", subject="user:claudia"):
    # let "claudia" create the issue on the "oursoftware" project
    create_issue()

For our example, the schema would be the following:

definition user {}

definition project {
    permission create_issue = todo
}

definition issue {
    permission assign = todo
    permission resolve = todo
    permission create_comment = todo
}

definition comment {
    permission delete = todo
}

For now I have used the placeholder todo for the actual computation of those permissions. You can see from our original example that these permissions closely follow the capabilities defined at the top of our control panel.

Header Bar Example

Modeling Roles

Since this is an article about user defined roles, we probably want to also model out the roles themselves. In order to keep permissions flexible in Authzed, we separate out permissions from relations. Relations define how objects can relate to other objects. Permissions are how we interpret relations to make access control decisions. Our role object will relate to users that have the role: member and to a project for which the role is defined.

definition role {
    relation project: project
    relation member: user
}

That’s it! Now that we have roles, we can start filling in the relations and definitions on our project, issue, and comment objects.

Adding Relations to Bind Roles to Capabilities

As mentioned earlier, we separate out permissions and relations in Authzed in order to keep the permissions computation flexible. The first thing we will do is add one relation for each grantable capability, and set the allowable type to users who are the members of a role:

definition project {
    relation issue_creator: role#member
    relation issue_assigner: role#member
    relation any_issue_resolver: role#member
    relation assigned_issue_resolver: role#member
    relation comment_creator: role#member
    relation comment_deleter: role#member

    permission create_issue = todo
}

This will allow us to grant a capability to a role with the following pseudocode:

# Grant the "issue_creator" capability to those who have been granted the "project_manager" role
authzed.write(resource="project:oursoftware", relation="issue_creator", subject="role:project_manager#member")

Now that we have the proper relations in place, we can compute all of the permissions we left blank earlier!

Computing Permissions from Relations

Permissions such as create_issue which are both granted and evaluated at the organization level, are very easy to compute:

definition project {
    relation issue_creator: role#member
    relation issue_assigner: role#member
    relation any_issue_resolver: role#member
    relation assigned_issue_resolver: role#member
    relation comment_creator: role#member
    relation comment_deleter: role#member

    permission create_issue = issue_creator
}

This permission, in short, says: "those who are members of any role which has an issue_creator relationship on this project, shall have the create_issue permission."

The rest of our permissions are evaluated on issue and comment, but the relationships are on the parent project object. How can we calculate these permissions?

Authzed schema provides an arrow -> operator which allows you to traverse through relationships matching a relation on an object, and then evaluate a relation on that object. For example, project->issue_assigner, says "follow any relationships with the project relation, and compute the issue_assigner relation on those objects. From here it is easy to see how to compute indirect permissions. We also need to add a relationship between the issue and project:

definition issue {
    relation project: project

    permission assign = project->issue_assigner
    permission resolve = ??? + project->any_issue_resolver
    permission create_comment = project->comment_creator
}

We have a problem though: one of the capabilities that we can grant is assigned_issue_resolver. We don’t have any way of knowing if a particular user is assigned to the issue. We can fix this with another relation, and now compute the resolve using that relation:

definition issue {
    relation project: project
    relation assigned: user

    permission assign = project->issue_assigner
    permission resolve = (project->assigned_issue_resolver & assigned) + project->any_issue_resolver
    permission create_comment = project->comment_creator
}

We use the intersection operation (&) to make sure that the user is both the assigned user, and is a member of a role which grants them the assigned_issue_resolver capability.

The last permission to fill in is the delete permission on comments. Similar to how issue objects are related to project objects, comment objects are related to issue objects. We can fill in the permission as follows:

definition comment {
    relation issue: issue
    permission delete = issue->???
}

Oh no! We found another problem. Until support for nested arrows is finished, the -> operator only lets us traverse one level and recompute a relation, but the capability for comment_deleter is nested two levels away on the project!

We can solve this problem and keep the schema normalized by creating a synthetic permission on the issue which will do the next step of the resolution. The complete example is as follows:

definition issue {
    relation project: project
    relation assigned: user

    permission assign = project->issue_assigner
    permission resolve = (project->assigned_issue_resolver & assigned) + project->any_issue_resolver
    permission create_comment = project->comment_creator

    // synthetic relation
    permission project_comment_deleter = project->comment_deleter
}

definition comment {
    relation issue: issue
    permission delete = issue->project_comment_deleter
}

The final capability that we haven’t modeled is the role_manager capability, which will be used to determine who is allowed to make changes to roles.

Adding Role Meta-Permissions

We need to add some permissions to the role itself in order to allow roles to be created, bound to capabilities, and bound to users. However, we don’t want the built-in roles to be modifiable. We can do this all in our schema as well!

First we will add a role_manager capability to our project object:

definition project {
    relation issue_creator: role#member
    relation issue_assigner: role#member
    relation any_issue_resolver: role#member
    relation assigned_issue_resolver: role#member
    relation comment_creator: role#member
    relation comment_deleter: role#member
    relation role_manager: role#member
    
    permission create_issue = issue_creator
    permission create_role = role_manager
}

Then we will add a built_in_role relation on our role object, and use that to disable certain permissions (delete, add_permissionon, and remove_permission) on the roles which would allow them to be modified or removed:

definition role {
    relation project: project
    relation member: user
    relation built_in_role: project
    
    permission delete = project->role_manager - built_in_role->role_manager
    permission add_user = project->role_manager
    permission add_permission = project->role_manager - built_in_role->role_manager
    permission remove_permission = project->role_manager - built_in_role->role_manager
}

By adding a built_in_role relationship with the project and using the exclusion operator, when any of the role modifying permissions are evaluated, the result will be an empty set of users since built in roles will have relationships for both project->role_manager and built_in_role->role_manager.

Putting It All Together

The following is the complete schema, which we can now start adding relationships to:

Integrating With an Application

We need to add a few things to an application before we can fully utilize the schema that we’ve developed. We need to:

  1. Bootstrap the permissions when we create a new project
  2. Write a relationship from an issue to a project whenever we create a new issue
  3. Write a relationship from a comment to an issue whenever we make a comment
  4. Write or delete a relationship whenever a role is modified to change the permission
  5. Protect all of the above with the proper permissions checks.

The following pseudocode blocks give a rough idea of how to do this for some of the operations in our application:

def create_project(creator_id, project_id):
    authzed.write([
        # Create the roles in the project
        (resource: "role:{project_id}-admin", relation: "project", subject: "project:{project_id}"),
        (resource: "role:{project_id}-developer", relation: "project", subject: "project:{project_id}"),
        (resource: "role:{project_id}-user," relation: "project", subject: "project:{project_id}"),
        (resource: "role:{project_id}-admin", relation: "built-in-role", subject: "project:{project_id}"),
        (resource: "role:{project_id}-developer", relation: "built-in-role", subject: "project:{project_id}"),
        (resource: "role:{project_id}-user", relation: "built-in-role", subject: "project:{project_id}"),

        # Grant the permissions for the built-in roles
        (resource: "project:{project_id}", relation: "issue_creator", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "issue_assigner", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "any_issue_resolver", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "assigned_issue_resolver", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "comment_creator", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "comment_deleter", subject: "role:{project_id}-admin"),
        (resource: "project:{project_id}", relation: "role_manager", subject: "role:{project_id}-admin"),

        (resource: "project:{project_id}", relation: "issue_creator", subject: "role:{project_id}-developer"),
        (resource: "project:{project_id}", relation: "assigned_issue_resolver", subject: "role:{project_id}-developer"),
        (resource: "project:{project_id}", relation: "comment_creator", subject: "role:{project_id}-developer"),

        (resource: "project:{project_id}", relation: "issue_creator", subject: "role:{project_id}-user"),
        (resource: "project:{project_id}", relation: "comment_creator", subject: "role:{project_id}-user"),

        # Bind the project creator as an admin
        (resource: "role:{project_id}-admin", relation: "member", subject: "user:{creator_id}"),
    ])

def create_issue(creator_id, project_id):
    if authzed.check(resource: "project:{project_id}", permission: "create_issue", subject: "user:{creator_id}"):
        issue_id = create_issue_in_database()

        # Bind the issue to the project in authzed
        authzed.write(resource: "issue:{issue_id}", relation: "project", subject: "project:{project_id}")

def assign_issue(assigner_id, assigned_to_id, issue_id):
    if authzed.check(resource: "issue:{issue_id}", permission: "assign", subject: "user:{assigner_id}"):
        # Assign the issue to the assigned_to
        authzed.write(resource: "issue:{issue_id}", relation: "assigned", subject: "user:{assigned_to_id}")

def create_role(creator_id, project_id, role_name):
    if authzed.check(resource: "project:{project_id}", permission: "create_role", subject: "user:{creator_id}"):
        # Bind the role to the project in authzed
        authzed.write(resource: "role:{project_id}-{role_name}", relation: "project", subject: "project:{project_id}")

def add_capability_to_role(creator_id, project_id, role_name, capability_name):
    if authzed.check(resource: "role:{project_id}-{role_name}", permission: "add_permission", subject: "user:{creator_id}"):
        # Bind the role to the capability in authzed
        authzed.write(resource: "project:{project_id}", relation: capability_name, subject: "role:{project_id}-{role_name}")

def add_member_to_role(creator_id, project_id, role_name, user_to_grant):
    if authzed.check(resource: "role:{project_id}-{role_name}", permission: "add_user", subject: "user:{creator_id}"):
        # Bind the role to the capability in authzed
        authzed.write(resource: "role:{project_id}-{role_name}", relation: "member", subject: "user:{user_to_grant}")

And so on.

Wrapping Up

I hope this has been a useful introduction to the theory and practice behind adding user defined roles to an application using Authzed. This powerful paradigm is often used to great effect in business-to-business and productivity apps, but you can even find similar permissions panels in consumer and social products. If you have any questions, get stuck adding user-defined roles to your own application, or just want to say hi, come join us on Discord.

Additional Reading

If you’re interested in learning more about Authorization and Google Zanzibar, we recommend reading the following posts:

Get started for free

Join 1000s of companies doing authorization the right way.