When the Oso team first started tackling problems in the authorization space, we looked to GitHub permissions as a canonical example of the not-so-simple authorization patterns that can quickly become overwhelming to maintain in application code.
What's so useful about GitHub's authorization model?
GitHub provides a pure example of what motivates authorization in the first place—securing access to resources. In GitHub, the resources are repositories, and users may or may not be able to take certain actions on them. Repositories may be created, destroyed, read, or edited in any number of ways, and have child resources (issues, wikis, branches, etc.) whose access control is related to that of their parent resource. And permissions hierarchies are not limited to resources. Users can be grouped into organizations, and further grouped into teams and sub-teams, which complicates the business of controlling access even more.
In this post, we'll show how to incrementally build complex policies with Oso. We'll use GitHub's authorization model as an example, but the patterns we cover, including ownership, hierarchical permissions, and nested resources, are common to many user-facing applications.
Note: this post will focus on Oso policies and authorization logic, and won't go into detail on how to use the Oso library. For more information on this, see our guide on adding Oso to your application. Oso's open-source libraries are available in Python, Ruby, Java and Node.js.
A note on GitHub permissions
Permissions in GitHub revolve around managing access to repositories. If you're unfamiliar with GitHub permissions, you can find a nice overview here.
Authorization Use Case #1: Basic access for repository owners and collaborators
Let's start GitHub's simplest authorization use case: user accounts and repositories. If I sign into my personal GitHub account and create a repository, I am the owner of that repository. To understand what this means, let's look at the permissions available for user-owned repositories:
- Admin
- Write
- Read
These three permission levels determine every action someone can take on a user-owned repository. As the owner, I get "ADMIN"
privileges, which means I can do whatever I want to my repository. "WRITE"
access goes to collaborators, which we'll talk about in a minute. And if the repository is public, the rest of the world gets to "READ"
it, whether or not they are even logged in. Let's look at what these rules might look like in an Oso policy. The policy below, like all Oso policies, is written in a declarative policy language called Polar.
# Owners can take any action on their own repositories
allow(actor: User, _action, resource: Repository) if
permission(actor, "ADMIN", resource);
# Organization owners are admins of all organization repositories
# Repository owners are admins of their own repositories
permission(actor: User, "ADMIN", resource: Repository) if
role(actor, "OWNER", resource);
# Rule specifying repository ownership,
# based on data stored on application objects
role(actor: User, "OWNER", resource: Repository) if
resource.owner matches User{id: actor.id};
# Anyone can read a public repository
permission(_actor, "READ", resource: Repository) if
not resource.isPrivate;
We've created a couple different kinds of rules here. The standard Oso [allow](https://docs.osohq.com/python/reference/glossary.html#allow-rules)
rule is the starting point for evaluating any authorization request. We can name our other rules whatever we want, and we've defined them to match GitHub's terminology. We're using role
rules to specify the role of a user with respect to a particular resource (i.e., "OWNER"
of a Repository
), and permission
to specify a user's permission level (i.e. "READ"
). We'll use these supplementary rules when we add more allow rules to the policy later on.
One other thing to note about these rules: the User
and Repository
types specified for actors and resources refer to the object types actually used in the GitHub application code. Oso makes it possible to pass application objects directly into the policy evaluation engine.
As mentioned earlier, there's another type of user that can access repositories owned by user accounts—collaborators. Repository owners can invite collaborators to a repository. We can access a repository's collaborators with the .collaborators
field; each collaborator has an id
field and a permission
field, which we can use in our policy.
# Look up an actor's permission level from the repository's collaborator list
permission(actor: User, role, resource: Repository) if
collab in resource.collaborators and
actor.id = collab.id and
role = collab.permission;
A quirk about GitHub permissions
As we've seen, the permissions on user-account-owned repositories are pretty basic and coarse. The next level of complexity in the GitHub authorization model is Organizations. As you might already know, GitHub repositories can also be owned by organization accounts. And it turns out that the permissions work quite differently with organizations than with user accounts. But let's see if we can use Oso to add organizations with as few changes as possible.
Authorization Use Case #2: Managing access within organizations
GitHub organizations have three user types:
- Owners
- Billing managers
- Members
We're going to focus on owners and members. Like repository owners, organization owners can take any action on the organization.
allow(actor: User, _action, resource: Organization) if
role(actor, "OWNER", resource);
# Rule specifying organization ownership
# Note that using the `Organization` specializer prevents it
# from conflicting with our rule for repository ownership :)
role(actor: User, "OWNER", resource: Organization) if
member in resource.membersWithRole and
member.role = "ADMIN" and
member.id = actor.id;
role(actor: User, "MEMBER", resource: Organization) if
member in resource.membersWithRole and
member.id = actor.id;
Notice that the logic for defining Organization
owners is different from the logic for defining repository owners. Organizations do not have an owner
field (there can be multiple owners). Instead, organizations store owners in the .membersWithRole
field, along with every other organization member. GitHub distinguishes owners from other members by giving them a role called "ADMIN"
(not to be confused with the "ADMIN"
permission level for repositories). Normal members simply have the "MEMBER"
role.
If you didn't get all that, not to worry. All you really need to know is that this logic reflects our best approximation of how the data would actually be stored in GitHub's application, which is why it looks a little strange.
We can now use the role
rules we've written to specify permissions for repositories within organizations. Org members can have any of the following permission levels for a repository in an organization:
- Read
- Triage
- Write
- Maintain
- Admin
Organization owners always have "ADMIN"
permissions, for all repositories. This means that we now have two conditions that always give users "ADMIN"
permissions on repositories: 1) if the user owns the repository, and 2) if the user owns the organization that owns the repository. The second condition can be enforced by adding one line to the existing rule for the "ADMIN"
permission:
# Repository owners are admins of their own repositories
# Organization owners are admins of all organization repositories
permission(actor: User, "ADMIN", resource: Repository) if
role(actor, "OWNER", resource) or
role(actor, "OWNER", resource.owner);
Owner permissions? Check. Let's move on to repository permissions for plain old members. GitHub offers two ways for members to gain repository access: base permissions and direct access. The base permission is the default repository permission given to all organization members:
# All organization members have access to repositories from the base role
permission(actor: User, permission, resource: Repository) if
org = resource.owner and
org matches Organization { baseRole: permission } and
role(actor, "MEMBER", org);
Direct access refers to permissions given directly to members by admins of the repository. Members with direct access can be found in the repository's list of collaborators. Yes, collaborators! Remember those from earlier? The rule we already wrote for collaborator permissions will work for organization-owned repositories as well.
Authorization Use Case #3: Assigning permissions through teams
As a quick recap, our policy now depends on three permission sources for repository access: ownership, organization base permissions, and direct access (or collaboration). And each new permission source required minimal, if any modifications to our existing rules. This is one benefit of declarative policies and additive rules. But we're not done yet—let's go over one more permission source for GitHub repositories: Teams.
Teams group members within organizations. Importantly for us, repository permissions can be assigned to teams in the same way that they can be assigned to individual members. Teams can have sub-teams, which inherit repository permissions from their parent teams. Recursive rules are useful for representing permission inheritance.
# We can write a permission rule for Teams
permission(team: Team, perm, resource: Repository) if
team in resource.owner.teams and
perm = team.permission;
# subteams inherit repo permissions from their parent team, if they have one
permission(team: Team, perm, resource: Repository) if
permission(team.parent, perm, resource);
# Users can get repo permissions from their teams
permission(actor: User, perm, resource: Repository) if
permission(team, perm, resource)
role(actor, "MEMBER", team);
# Is a user a team member?
role(actor: User, "MEMBER", team: Team) if
member in team.members and
actor.id = member.id;
Authorization Use Case #4: Controlling repository access based on permissions
So far, we've focused on the logic that determines the permission level of a given user on a repository. We've looked at many sources for permissions, including roles, base permissions, and teams. Now let's put those permissions to work and start handling some real authorization queries. GitHub provides a many-rowed chart that specifies what each permission level is allowed to do to a repository:
Hierarchical Permissions
Let's see what a few of these rules would look like with Oso. The first thing to notice when looking at this chart is that the permissions have a hierarchical structure. Someone with "ADMIN"
permissions automatically has "MAINTAIN"
, which automatically has "WRITE"
, and so on. This is a common pattern. To make it easier on ourselves, we can define this permissions hierarchy upfront, and never have to worry about it again. That way, we can write rules based on the minimum-required permission, and all other permissions will inherit the rule by default. Here's how the permissions hierarchy looks in our policy:
permission(actor: User, "READ", resource: Repository) if
permission(actor, "TRIAGE", resource);
permission(actor: User, "TRIAGE", resource: Repository) if
permission(actor, "WRITE", resource);
permission(actor: User, "WRITE", resource: Repository) if
permission(actor, "MAINTAIN", resource);
permission(actor: User, "MAINTAIN", resource: Repository) if
permission(actor, "ADMIN", resource);
Now, a rule that says anyone can pull or fork a repository if they have "READ"
permission on that repository effectively says that anyone with any permissions on that repository can pull or fork it, since all other permissions inherit "READ"
by default.
allow(actor: User, action, resource: Repository) if
action in ["pull", "fork"] and
permission(actor, "READ", resource);
Securing nested resources
Let's take a closer look at accessing Issues. We can easily say that anyone with "READ"
permission on a repository can open an issue.
allow(actor, "open_issue", resource: Repository) if
permission(actor, "READ", resource);
Likewise, we can also express that anyone with "TRIAGE"
permission on a repository can close any issue.
allow(actor, "close_issue", resource: Repository) if
permission(actor, "TRIAGE", resource);
But what about the rule that says anyone with "READ"
access can close their own issues? We could write something like:
allow(actor, action, resource: Repository) if
action = "close_own_issue" and
permission(actor, "READ", resource);
But that doesn't feel right, because we should be checking the ownership of the issue in the policy, not the application. In fact, the resource here isn't really the Repository
, it's actually the Issue. Recall that Oso rules can be written directly over application objects that are passed into the policy engine (which is why we can write something like Repository.owner
in our policy). Here's where that really comes in handy: we can easily change the resource our rules are written over from Repository
to Issue
, allowing us to access all the issue's application data from inside Oso policies:
allow(actor, action, issue: Issue) if
action in ["close"] and
permission(actor, "TRIAGE", issue.repository);
allow(actor: User, action, issue: Issue) if
action in ["open", "edit", "close"] and
actor.id = issue.submittedBy.id and
permission(actor, "READ", issue.repository);
The strategy of writing rules over sub-resources can also be applied to comments, pull requests, wikis, and any other Repository
sub-resources that need to expose their own data to the policy.
GitHub's open issue
Before we wrap up, let's look at one more feature you could build with Oso. Github has an open issue for "custom roles with fine-grained repo permissions." This feature would allow repository admins to create their own roles (what we have been referring to as "permissions", pardon the confusion) with custom repository access rules.
Let's say that GitHub implements this feature by storing custom permissions on Organization
objects, and that a custom permission has a name
field (e.g., "CUSTOM_READ"
), and an actions
field that stores the actions this permission allows users to take on repositories (e.g. ["pull", "push", "fork"]
).
We could integrate this new custom roles system into our existing Oso policy with one rule:
# 1. look up the user's permission (bound to the `perm` variable)
# 2. if their permission is in the Organization's list of custom permissions,
# use its list of actions to authorize the request
allow(actor: User, action, repo: Repository) if
permission(actor, perm, repo) and
custom_perm in repo.owner.customPermissions and
custom_perm matches { name: perm, actions: allowed_actions } and
action in allowed_actions;
This might not be exactly how GitHub chooses to implement this feature, but Oso's embedded nature lets it use the whatever data model the application developer chooses. Oso's flexible design aims to simplify the process of extending and modifying your permissions system.
Wrapping up
GitHub's permissions scheme is a useful testing ground for Oso because it demonstrates many common, but not-so-simple, authorization patterns. In this post, we covered:
- Defining resource-scoped permissions
- Defining resource-scoped roles
- Defining resource ownership
- Controlling access based on resource ownership
- Permission inheritance between resources
- Hierarchical permissions
- Securing nested resources
We've hopefully shown that Oso simplifies the process of expanding a permissions system, and facilitates writing policies that naturally represent relationships between actors, actions, and resources as they exist in your application.
At the very least, you're now a semi-expert on GitHub's permissions model. This post is meant to be less a guide, and more a relatable example of the authorization logic that Oso policies can express.
Getting started with Oso
If you'd like to learn more, you can check out our quickstart, or to see how to add Oso to your application. We're considering writing a follow-up post on how to add Oso to a GitHub-like application; if that's something you're interested in, drop us a note. If you have questions, feedback or just want to learn how to expertly manage access to your repos, join us on Slack or open an issue.