Introducing Built-in Roles with Oso

A month ago, we introduced a new guide to building roles with oso and hinted that we would be building some Role-Based Access Control (RBAC) features into our libraries soon. Two weeks ago, we released a preview of those features in our sqlalchemy-oso package. This week, we have polished those features up, written some docs, and are excited to share a sample app showcasing our new out-of-the box roles features!

We are giving you an API for declaratively creating roles, relating them to users and resources, and managing them through helper methods. We also generate Polar rules that you can use in your oso policies to govern the roles the roles you defined.

We're working hard to build these features for other languages and ORMs in the future, but the patterns we've implemented in sqlalchemy-oso are already generalizable across many languages and frameworks. Interested in using this for your framework? Open a GitHub issue to let us know!

In this post, we'll go through the design of our new features, how they relate to our broader thinking on roles, and the sample app we used to validate and showcase them.

How we structured role features in sqlalchemy-oso

A refresher—our view on Role-Based Access Control

We've defined our view of roles in our documentation, but as a refresher: a role is a named group of permissions that can be assigned to users to streamline authorization logic and management.

Our roles library focuses on roles from the perspective of a B2B application, and optimizes for the problems that often arise in that context. These problems include scoping roles to tenants and resources, applying roles to nested resources and groups, inheriting permissions from one role to another, and exposing roles to end users. For more detail on the library and how it works, here are the docs.

We decided to build role features at the ORM layer because we believe that role data should live alongside application data, and an ORM provides an access point to application data models. Baked into that belief is an important distinction between the data that maps roles to users and resources, and the logic that governs what roles allow users to do. We've previously described the former as "user-role" mappings, and the latter as "role-permission mappings."

Role data vs. role logic

Role data, which includes the roles themselves—their names, the type of resource they are scoped to, and any other metadata—as well as their relationships with specific users and resources, is nearly impossible to separate from your application. Roles need to be defined with respect to your application data model, and therefore are difficult to export to a different storage system. By integrating at the ORM layer, oso can add a role structure that utilizes your existing models and doesn't require you to export or copy any data.

Role logic,* the mappings between roles and permissions, *generally changes less frequently than role data, and is easier to separate from your application than role data. As with any authorization logic, we believe it is useful to centralize role logic in a policy. Our roles module helps you do this by providing a base oso policy for roles that enables special rules like role_allow and resource_role_applies_to, so that you can write rules over role data directly, and specify how roles should apply between resources.

Using sqlalchemy-oso to build GitHub's role-permissions model

As we began experimenting with roles, we returned to GitHub as a quintessential example of authorization in a B2B app (see our previous post on GitHub's authorization model). GitHub's permissions model includes many of the patterns common in B2B web applications: roles scoped to tenants (organizations) and resources (repositories), nested resources and user groups (teams), hierarchical roles, an expansive permissions user interface, and complex authorization logic.

So we built (another) GitHub sample app. The app provides a holistic example of how one might use roles in a real application, which is why we think it's worth going through in this post.

The main relevant components of the app's authorization system are:

  • The data model (actors, resources)
  • The role data model
  • The oso policy
  • Policy enforcement

We'll cover each component briefly, and along the way talk through how the app uses the roles library to implement common roles system patterns.

The application data model

The app's data model has a structure common to many B2B apps: a multi-tenant model with nested resource and user groups. We built the following actor and resource models into the app to represent GitHub's model:

  • Organization: Organizations are the top-level grouping of users and resources in the app. As with the real GitHub, users can be in multiple Organizations, and may have different permission levels in each. Organizations are treated as resources.
  • User: GitHub users. Users can have roles within Organizations, Teams, and Repositories. Users are treated as actors.
  • Team: Groups of users within an organization. They can be nested. Teams can have roles within Repositories. Teams are treated as both actors and resources.
  • Repository: GitHub repositories, each is associated with a single organization. Repositories are treated as resources.
  • Issue: GitHub issues; each is associated with a single repository. Issues are treated as resources.

The role data model

Resource-specific roles

GitHub's roles system includes three different types of roles: roles scoped to Organizations, roles scoped to Repositories, and roles scoped to Teams. All of these roles can be modeled as resource-specific roles, which we have written about in our guide to roles patterns. Permissions granted by a resource-specific role only apply to that resource. Since this pattern is so common, we made resource-specific roles the fundamental building block of the roles features in the sqlalchemy_oso library. The package represents each role type as a SQLAlchemy model that corresponds to a table in the database. These tables are generated by the sqlalchemy_oso.roles.resource_role_class() method, which creates a mixin class for each role type. The role mixins can be extended to create SQLAlchemy models for each role type.

Every role model defined with the oso library has the following characteristics:

  • Role name, one of a pre-defined set of role choices (e.g., "Admin", "Read", "Write", etc.)
  • Relationship to a user model that represents the users the roles will be assigned to (e.g., User)
  • Relationship to a resource model that represents the resource the roles will be scoped to (e.g., Organization)

The role models we use in the GitHub example are:

  • OrganizationRole: roles scoped to Organization resources, based on GitHub's organization roles
  • TeamRole: roles scoped to Team resources, based on GitHub's teams
  • RepositoryRole: roles scoped to Repository resources, based on GitHub's repository permission levels

Here's what the role definitions look like in the app:

# app/models.py

## ROLE MODELS ##

RepositoryRoleMixin = resource_role_class(
    declarative_base=Base,
    user_model=User,
    resource_model=Repository,
    role_choices=["READ", "TRIAGE", "WRITE", "MAINTAIN", "ADMIN"],
)
class RepositoryRole(Base, RepositoryRoleMixin):
    # add the team relationship as a custom column on the class
    team_id = Column(Integer, ForeignKey("teams.id"))
    team = relationship("Team", backref="repository_roles", lazy=True)

OrganizationRoleMixin = resource_role_class(
    Base, User, Organization, ["OWNER", "MEMBER", "BILLING"]
)
class OrganizationRole(Base, OrganizationRoleMixin):
    pass

TeamRoleMixin = resource_role_class(Base, User, Team, ["MAINTAINER", "MEMBER"])
class TeamRole(Base, TeamRoleMixin):
    pass

Notice that we've add a custom relationship between the RepositoryRole and Team models (it's totally cool to customize the role models!). We've added this relationship because in GitHub, repository roles can be assigned to both users (User) and teams (Team). We'll talk about how to use this relationship to transfer roles from teams to their users in the next section.

The oso policy

As mentioned earlier, the oso policy is where role logic is defined. This is where developers can specify what each role should allow users to do. But the policy can also be used to implement more complicated permissions logic, like hierarchical roles, cascading roles from parent to child resources, and giving users permissions based on the roles of their teams. We'll cover a few of the more interesting policy examples here. For a more general overview of the roles library usage, see our library documentation.

Assigning permissions to roles

The fundamental rule involved in a role-based oso policy has the form role_allow(role, action, resource). For example, this rule allows users with the "BILLING" organization role to take the "READ_BILLING" action on organizations.

# authorization.polar

role_allow(
    _role: OrganizationRole{name: "BILLING"}, 
    "READ_BILLING", 
    _organization: Organization
);

With this rule in place, a query to Oso.is_allowed(user_1, "READ_BILLING", organization_1) will return True if user_1 is a member of the "BILLING" role for organization_1.

Hierarchical roles

All of the roles in the app have some hierarchical element to them. Hierarchical roles inherit permissions from one another based on their position in the hierarchy. The roles library provides a built-in policy rule to specify role hierarchies, <resource>_role_order([ROLE_NAMES]).

The following rules in app/authorization.polar specify the hierarchies for the GitHub example:

### Specify repository role order (most senior on left)
repository_role_order(["ADMIN", "MAINTAIN", "WRITE", "TRIAGE", "READ"]);

### Specify organization role order (most senior on left)
organization_role_order(["OWNER", "MEMBER"]);
organization_role_order(["OWNER", "BILLING"]);

### Specify team role order (most senior on left)
team_role_order(["MAINTAINER", "MEMBER"]);

These rules mean that role_allow rules assigning permissions to the organization "MEMBER" role will also be evaluated for the "OWNER" role.

Using roles with user groups

Assigning roles to groups of users, rather than individual users, is a common roles use case. As shown earlier, the Team model represents groups of users in our GitHub app. Since both teams and users can have repository roles, an individual user can derive permissions from both their own roles and the roles of teams that they are in. We expressed this in our oso policy by adding a user_in_role rule. user_in_role is another rule built-in to the roles library, that takes in a user, unbound role variable and resource, and then binds the role variable to the user's roles for the resource. By default, user_in_role rules are defined for roles assigned directly to users. But for roles implied by a different relationship, such as team membership, additional user_in_role rules can be added to the policy. The following rule binds the role variable to the repository roles of the user's teams.

### Users inherit repository roles from their teams
user_in_role(user: User, role, repo: Repository) if
    team in user.teams and
    role in team.repository_roles and
    role.repository.id = repo.id;

Nested resources

We have three resource-specific roles in the application, but each of those resources has more resource types nested inside it. GitHub, like many apps, applies the roles associated with a top-level resource to the resources nested within that resource as well. For example, someone with the "READ" role in a repository should also be able to read all the repository's issues, even though the Issue model doesn't have an explicit role associated with it.

This is implemented in the oso policy with a combination of role_allow rule and another built-in rule, resource_role_applies_to(child_resource, parent_resource). resource_role_applies_to is used to use roles scoped to one type of resource (the parent_resource) and apply them to another type of resource (the child_resource).

The following rules show how we control access to nested resources in the GitHub app:

### An organization's roles apply to its child repositories
resource_role_applies_to(repo: Repository, parent_org) if
    parent_org := repo.organization and
    parent_org matches Organization;

### A repository's roles apply to its child issues
resource_role_applies_to(issue: Issue, parent_repo) if
    parent_repo := issue.repository;

Policy enforcement

All our interesting policy logic only matters if it is enforced in the application itself. We often hear that it's difficult to mix and match role-based access control with more fine-grained policy logic. So, we made sure that policy enforcement with oso roles works the same way it does without roles: calls to Oso.is_allowed() in the oso python package. If you use Flask, you can use FlaskOso.authorize() in the flask-oso package instead, like we do in the GitHub example. Because the interface to the policy is the same with roles or without, you can use the roles features alongside your normal oso rules without changing your enforcement code.

Future roles features: SQLAlchemy and beyond

We currently only support built-in roles features in our oso-sqlalchemy library, but we plan to expand support to other ORMS early in the new year. Other things we're thinking about working on next include:

We love to hear from the oso community! If you're interested in either of these areas, please like or comment on the linked issues. Or, if you would like to see other roles features in the future or have ideas for anything we can do better, open another issue or a PR on GitHub. If you'd like to see how people are using oso, ask our engineering team questions, or just hang out, join us on Slack.

Want us to remind you?
We'll email you before the event with a friendly reminder.

Write your first policy