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 toOrganization
resources, based on GitHub's organization rolesTeamRole
: roles scoped toTeam
resources, based on GitHub's teamsRepositoryRole
: roles scoped toRepository
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.