A bear playing hopscotch

Authorization Rules are always harder than you think

Graham Neray

Rules are Hard Because They Evolve Over Time

The fundamentals of authorization are encapsulated in this single snippet:

def update_post(user, post)
   if not user.admin?
       raise Forbidden, "you must be an admin to update this post"
   end
   ...
end

These six lines contain the three core components of authorization:

This is Part 2 of our 5-part series on Why Authorization is Hard. Today, we’re talking about rules. In our initial post, Why Authorization is Hard, we called this “ modeling” but “rules” better defines the problem.

Part 1: Why Authorization is Hard

Part 2: Why Rules are Hard

Part 3: Why Data is Hard

Part 4: Why Enforcement is Hard

Part 5: Why Opinion Is the Answer to Complexity


“All men are mortal. Socrates is a man. Therefore, Socrates is mortal.”

Logic sits at the core of authorization. Logic is the rule set that governs who can do what in your application. The start of that Aristotelian syllogism can be reworked for GitHub access:

All ‘Owners’ have repo access.

This is the rule that decides the access.

Of course, just as predicate logic isn’t as simple as that one statement from Aristotle, so authorization rules are rarely as easy as a single “has/is/therefore.”

GitHub is a great example of this. How many access controls are based on other attributes or roles a user may have?

The difficulty of rules stems from this complexity, in two ways:

The best solutions follow from these challenges – they have opinions to help you

Let’s go through these two challenges, and how the existing solution space approaches them.

Defining an initial rule set

On the surface, this seems easy. Most applications start with some version of a basic admin role. It’s trivial to add an admin boolean to your user table and add in the corresponding initial logic to your application.

But the ease with which you can implement this belies the danger of it. What’s missing at this point isn’t ability, it’s opinion. You are free to add any rule, or none, at this point. This leads to an implement now, reason later approach.

And that single boolean is now something you have to think about for all changes in not just your authorization logic, but your application logic too.

Relying on a quick ‘n dirty implementation like this can lead to several issues, including:

A thoughtful, opinionated rule set should:

Authorization evolves over time

Here’s something we hear when talking to engineers all the time:

“We’ve rewritten our authorization system 3 or 4 times.”

This is the default when rolling your own authorization system. How can it not be? You’re not going to put your initial engineering resources into building out a complex access control system that you don’t even know if you’ll need. You’ll build for the simple and quick, and refactor later.

And if you see even any kind of success, the refactors will come. Even within a simple B2B application, what might originally be organization-level roles can quickly shift to not just resource-specific roles but organization-level, resource-specific roles with attributes. (You can learn more about different types of authorization models here.)

Most applications mix and match these models in whatever way makes sense for their businesses. Ultimately, authorization logic is subordinate to business logic. Here, we propose a maturity model for rules based on how companies need and use different authorization rule sets over time. This is important as it allows organizations to understand where they are right now regarding authorization, and the changes they’ll need to plan for and make as they scale.

This maturity model goes from coarse-grained to fine-grained, and from static to dynamic.

Screenshot 2023-06-26 at 3.23.58 PM.png

When a company is feeling pain and/or saying, “we’re rewritten our authorization system 3 or 4 times,” that usually corresponds to moving through these stages.

This maturity model also outlines the best solutions at each stage. As the stages progress, the rules you use move from coarse to fine. The solutions also change.

Just like you use opinionated web frameworks to build web apps, you need opinionated authorization frameworks to build rules. And just as you implement more custom features as your application matures, so too you also need that kind of flexibility in authorization.

Application-wide access

This is where it starts.

The requirement here is often just gating access to certain parts of the application. The common ways to do this are:

Resource-specific access

Eventually, the application-wide access model doesn’t cut it. You need additional granularity.

A common driver for moving to this stage is adding collaborative features. You want your users to be able to own, view, edit, and share files.

Moving to this stage can be a substantial undertaking. In the previous incarnation, all you need is the user and the feature (nb: features are a kind of resource, just more coarse-grained). In resource-based systems, you need not just users and features, but data on resources N levels down the hierarchy.

Resources and actions are the primary entities, and access control policies are defined accordingly. Now the rules revolve around the resources. Take GitHub. All the rules revolve around your resources–your organization, your repositories, your files. And then from this foundation, roles (such as owner, member, or contributor) and attributes (such as public or private repositories) can be added. At this stage, it’s likely you need at least:

User-configurable permissions

The next step up in complexity is allowing users to configure not just the data (i.e., who is assigned to what role), but also some of the rules. What was once static is now dynamic.

At the lower end of the spectrum, you have the ability to add or remove specific permissions from a role. Then the ability to configure default roles. Then the ability to configure custom roles.

This increases complexity significantly, though in all cases the logic is still bounded within a predetermined set of rules that you’ve put in front of them.

This is a significant step, but one that enterprise customers will ultimately want from any sufficiently successful b2b product. It’s also unnervingly hard to implement if the system wasn’t designed with this dynamicism in mind. For instance, GitHub only launched custom repository roles in 2022 (14 years after the company was founded).

Custom policies

Few make it to this level of complexity.

We mostly see custom policies in “platformy” use cases. The scenario for custom policies is: I don’t know at all how my users will need to use my product, and so rather than specify behaviors, I’ll let them write their own rules.

Custom policies can still be combined with roles and relationships, but what distinguishes this use case is that users provide not only dynamic data but also dynamic rules.

Solving the complexity of authorization rules

Rules complexity exists, and grows over time. To make this complexity more manageable, you can use tools to abstract away some of this complexity.

Good solutions need to have some and ideally all of the following attributes:

Without these, you will forever be rewriting your rules.

There are roughly speaking three classes of solutions in this domain:

With each of these tools there are trade-offs – the tension between simple systems that help you get going and flexible systems that ensure you can cover any use case you need.

1. Language-specific libraries

It’s hard to talk about all of these in one sweeping statement since there are so many of them and thus a lot of variability, but the running thread is:

For example, in Pundit it’s easy to do anything that is a simple attribute check on the user/resource. If we wanted to implement “users can comment on issues if they are a repository admin, or the issue is not closed”, we might write the following:

class IssuePolicy
   def comment?
       user.admin? || !issue.closed?
   end
end

(source: Pundit Policies)

The problem here is that this doesn’t cover the resource-specific part. It only checks the global admin role.

A resource-specific roles model would require a custom implementation using the Rails integration to fetch the right data and or custom SQL to provide the correct list filtering.

In terms of complexity, you can push it as far as you want and support anything you want with your native language, but you will often run out of runway at various points and wind up doing multiple rewrites.

2. Zanzibar clones

Zanzibar is based entirely on ReBAC. The data model is also based on relationships, so you’ll find everything is expressed as relationships.

For example, Auth0’s OpenFGA defines resource-specific roles as relationships. Here, a repository has four relationships for admin, reader, issue_closer, and issue_viewer:

type repository
   relations
       define admin as self
       define reader as self
       define issue_closer as admin
       define issue_viewer as reader or admin

(Source: Roles and Permissions)

Check:

// Run a check
const { allowed } = await fgaClient.check({
   tuple_key: {
       user: 'bob',
       relation: 'issue_viewer',
       object: 'repo:anvil',
   },
});

And it handles attributes by writing wildcard relation tuples:

await fgaClient.write({
   writes: {
       tuple_keys: [
           // * denotes that the user is every user and object
           { user: '*', relation: 'view', object: 'repo:anvil'}
       ]
   }
});

Zanzibar clones are overkill for the simplest systems, but provide decent coverage for the middle stages of the maturity model. The double-edged sword of the relationship-based model is that while you have something to anchor on (relationships):

For some, this model works great. For others, it’s less intuitive. But as an application matures and requires more custom and dynamic logic, needing to model everything as a relationship becomes more and more complex.

3. Domain-specific languages (DSLs)

The two main examples of these are Oso, which focuses on application authorization, and Open Policy Agent (OPA), focused on infrastructure authorization.

OPA recommends implementing RBAC here. The general approach looks like this:

# user-role assignments
user_roles := {
   "alice": ["engineering", "webdev"],
   "bob": ["hr"]
}
# role-permissions assignments
role_permissions := {
   "engineering": [{"action": "read",  "object": "server123"}],
   "webdev":      [{"action": "read",  "object": "server123"},
                                   {"action": "write", "object": "server123"}],
   "hr":          [{"action": "read",  "object": "database456"}]
}

In this example, we have global roles that grant access to specific resources, presumably for an internal use case. Here, the data is inlined in the policy, but could also come from an external data store. These actually look more like groups and direct permissions (ACLs), since Alice belongs to two groups, which grant permissions on specific resources.

Because this model doesn’t have an opinion on roles, building them out is more complicated than in a more opinionated solution such as Oso. (For more examples of common patterns, you can read the documentation on how to model your app’s authorization). For example, we might say that repository admins can comment on all issues, and users can comment on open issues:

resource Issue {
   permissions = ["comment", ...];
   relations = { repository: Repository };
   "comment" if "admin" on "repository";
}
has_permission(user: User, "comment", issue: Issue) if
   is_closed(issue, false);

Domain-specific languages can support custom roles and mix and match as needed. They are probably overkill for the very simplest models, such as global roles.

Opinion abstracts complexity

Let’s go all the way back to that initial snippet. You’re creating an admin role in your application. How is it going to work? What needs to be included, and what doesn’t? What permissions are needed? How are you going to implement it?

This is the Oso answer:

actor User { }

global {
   roles = ["admin"];
}

resource Organization {
 roles = ["admin", "member", "internal_admin"];
 permissions = ["read", "write"];

 # internal roles
 "internal_admin" if global "admin";
 "read" if "internal_admin";

 "member" if "admin";

 "read" if "member";
 "write" if "admin";
}

This is the Oso policy you can use in Oso Cloud to grant the right permissions and set up global admin roles and resource-specific roles. It is our opinion on the right default for this type of authorization. There are others like it, but this one is ours.

This opinion is the role of the tools you use. In this case, DSLs such as Oso have opinionated takes on what are the right defaults for authorization. This is how you get past that scaling problem. At every level of the maturity model, an opinionated framework like Oso gives you a “default” way of implementing what you need.

And those defaults are built on top of each other. With the single policy above, you have global roles. Over time, you can add more roles and more models and mix and match as you like, without refactoring the rest of your application. You can even change this policy.

Defaults like this allow you to abstract away some of the complexity of what you are building. You can iterate and ‘hack’ all you need to build the best product for your users, and the Oso framework will support you all the way.

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

Write your first policy