Let's build the GitHub authorization model

Leina McDermott

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.

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 use the oso library. For more information on this, see our guide on adding oso to your application. As of this writing, 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.

Basic access: owners and collaborators

Let's start with 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 {% c-line %}"ADMIN"{% c-line-end %} privileges, which means I can do whatever I want to my repository. {% c-line %}"WRITE"{% c-line-end %} goes to collaborators, which we'll talk about in a minute. And if the repository is public, the rest of the world gets to {% c-line %}"READ"{% c-line-end %} 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.

{% c-block language="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;
{% c-block-end %}

We've created a couple different kinds of rules here. The standard oso allow 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 {% c-line %}role{% c-line-end %} 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 {% c-line %}User{% c-line-end %} and {% c-line %}Repository{% c-line-end %} 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 {% c-line %}.collaborators{% c-line-end %} field; each collaborator has an {% c-line %}id{% c-line-end %} field and a {% c-line %}permission{% c-line-end %} field, which we can use in our policy:

{% c-block language="polar" %}
# 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;
{% c-block-end %}

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.

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.

{% c-block language="polar" %}
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;
{% c-block-end %}

Notice that the logic for defining organization owners is different from the logic for defining repository owners. Organizations do not have an {% c-line %}owner{% c-line-end %} field (there can be multiple owners). Instead, organizations store owners in the {% c-line %}.membersWithRole{% c-line-end %} field, along with every other organization member. GitHub distinguishes owners from other members by giving them a role called {% c-line %}"ADMIN"{% c-line-end %} (not to be confused with the {% c-line %}"ADMIN"{% c-line-end %} permission level for repositories). Normal members simply have the {% c-line %}"MEMBER"{% c-line-end %} 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 {% c-line %}"ADMIN"{% c-line-end %} permissions, for all repositories. This means that we now have two conditions that always give users {% c-line %}"ADMIN"{% c-line-end %} 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 {% c-line %}"ADMIN"{% c-line-end %} permission:

{% c-block language="polar" %}
# 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);
{% c-block-end %}

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:

{% c-block language="polar" %}
# 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);
{% c-block-end %}

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.

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.

{% c-block language="polar" %}
# 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);

# Check if user is a team member.
role(actor: User, "MEMBER", team: Team) if
    member in team.members and
    actor.id = member.id;
{% c-block-end %}

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 {% c-line %}"ADMIN"{% c-line-end %} permissions automatically has {% c-line %}"MAINTAIN"{% c-line-end %}, which automatically has {% c-line %}"WRITE"{% c-line-end %}, 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:

{% c-block language="polar" %}
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);
{% c-block-end %}

Now, a rule that says anyone can pull or fork a repository if they have {% c-line %}"READ"{% c-line-end %} permission on that repository effectively says that anyone with any permissions on that repository can pull or fork it, since all other permissions inherit {% c-line %}"READ"{% c-line-end %} by default:

{% c-block language="polar" %}
allow(actor: User, action, resource: Repository) if
    action in ["pull", "fork"] and
    permission(actor, "READ", resource);
{% c-block-end %}

Securing nested resources

Let's take a closer look at accessing Issues. We can easily say that anyone with {% c-line %}"READ"{% c-line-end %} permission on a repository can open an issue:

{% c-block language="polar" %}
allow(actor, "open_issue", resource: Repository) if
    permission(actor, "READ", resource);
{% c-block-end %}

Likewise, we can also express that anyone with "TRIAGE" permission on a repository can close any issue:

{% c-block language="polar" %}
allow(actor, "close_issue", resource: Repository) if
    permission(actor, "TRIAGE", resource);
{% c-block-end %}

But what about the rule that says anyone with "READ" access can close their own issues? We could write something like:

{% c-block language="polar" %}
allow(actor, action, resource: Repository) if
    action = "close_own_issue" and
    permission(actor, "READ", resource);
{% c-block-end %}

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 {% c-line %}Repository{% c-line-end %} to {% c-line %}Issue{% c-line-end %}, allowing us to access all the issue's application data from inside oso policies:

{% c-block language="polar" %}
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);
{% c-block-end %}

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

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., {% c-line %}"CUSTOM_READ"{% c-line-end %}), and an actions field that stores the actions this permission allows users to take on repositories (e.g. {% c-line %}["pull", "push", "fork"]{% c-line-end %}).

We could integrate this new custom roles system into our existing oso policy with one rule:

{% c-block language="polar" %}
# 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;
{% c-block-end %}

This might not be exactly how GitHub chooses to implement this feature, but what we see here is that we can use whatever data model the application developer chooses to extend and modify the permissions system to add this new feature.

Wrap 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 shown how to use oso to implement these patterns incrementally, and exposed the core building blocks of oso that you could use to implement your own authorization model.

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. You can check out our docs for more examples of oso policies, 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.

Get updates from oso.

We won't spam you. Ever.