Five common authorization patterns

Mar 22nd, 2023

Omri Gazitt avatar

Omri Gazitt

Authorization

Five Common Authorization Patterns

Introduction

Authorization is complex because every app has to invent its own authorization model. Yet there are some well-worn paths which can be good starting points for most applications. This post goes through these patterns, and how an authorization platform (such as the Topaz open source project, or the Aserto authorization service) can help you implement them.

Roles as user properties

The simplest authorization pattern models a set of roles as properties of the user. These roles can be configured in the identity provider (IDP) and are often embedded as scopes in the access token generated by the IDP.

Some applications authorize entirely based on roles (or discrete permissions) embedded in access tokens. But this suffers from a few drawbacks:

  • Role / permission / scope explosion: the more roles/permissions there are, the more scopes need to be embedded in the access token, leading to size problems.
  • Coupling between the IDP and the app: any time a new permission gets added to the app, the code that generates additional scopes in the access token must also be revised. This is often done by the security/identity & access team that has access to the IDP, and introduces workflow complexities.
  • Once issued, access tokens are hard to invalidate. The authenticated user has permissions for as long as the access token is valid, even if their role has changed since the token was issued. This in turn leads to security holes.

In this scenario, using an authorization service such as Topaz provides a few advantages:

  • Adding an explicit authorization system lets the application check in real-time whether the user still has a role or permission.
  • The authorization code can be lifted out of the application and expressed as a policy. This makes it easier to reason about the authorization logic across the entire application.
  • Each API can have a different authorization policy that contains the logic used to authorize the operation. An example policy could be “Allow the operation if the user has the ‘admin’ or ‘editor’ roles, or the ‘create’ permission.”
  • Any role changes (or the value of a global “disabled” flag on a user) can be transmitted to the authorization system in near real-time. This closes off security issues associated with blindly trusting scopes embedded in access tokens.
  • The role-to-permission mapping can be done in the authorization system. As a result, the IDP only has to know about user-to-role mappings, not about permissions. This helps decouple the application from the IDP.

Group-based RBAC

The next pattern relies on groups (and group hierarchies) as a way to organize users.

Most applications have coarse-grained roles, such as “super-admin,” “admin,”  “editor,” “viewer,” “billing-admin,” that determine permissions to objects across the entire tenant.

These roles are typically assigned by making a user a member of a group. The group membership means that the user has been granted a role. Groups can be organized into hierarchies. For example, the “auditor” group can include “internal-auditors” and “external-auditors.” These two groups can in turn include specific users.

This is essentially the model that LDAP and Active Directory are built around. As a consequence, most authorization systems support groups as a core part of their model.

For example, Topaz and Aserto have a built-in “group” object type. The group object type has a “member” relation type, and the target for that relation can be any subject (user or group). This model enables including groups in other groups.  Checking group membership is transitive: when Topaz’s check_relation built-in function is called with a user and a group instance, it will walk through the group hierarchy and return true if the user is a member of the group, either directly or transitively.

The following policy (written in the Open Policy Agent’s Rego language) uses Topaz’s check_relation built-in to evaluate whether a user is a member of a group, and allow the operation if they are:


allowed {
  ds.check_relation({
    "subject": { "id": input.user.id },
    "relation": { "object_type": "group", "name": "member" },
    "object": {
      "type": "group",
      "key": input.resource.key 
    }
  })
}

Since a permission can be granted via more than one role, policies may need to check group membership for each of the corresponding groups. For example, the can-view permission may be granted if the user is a member of any of the “viewer,” “editor,” or “admin” groups. This would be accomplished by a policy such as the following:

groups := { "viewer", "editor", "admin" }
allowed {
  ds.check_relation({
    "subject": { "id": input.user.id },
    "relation": { "object_type": "group", "name": "member" },
    "object": { "type": "group", "key": groups[_] }
  })
}

But this can get complicated, and arguably just moves the complexity from the application logic to the policy. The next pattern aims to address this.

Group-based RBAC with fine-grained permissions

Permissions can be included in more than one role. In the example above, the can-view permission is likely included in the “viewer,” “editor,” and “admin” roles. A more scalable authorization system will define a set of discrete permissions, and assign those to roles.

Authorization systems often define permissions as first-class concepts. Instead of checking whether a user is a member of a group, the policy can check whether a user has a permission.

For example, Topaz allows associating permissions with relation types (aka “roles”). It also allows roles to include other roles - for example, the “editor” role can include the “viewer” role. The following Aserto manifest file does just that. It defines a “system” object type, and underneath it there are two relation types: “editor” and “viewer.” The “editor” relation type includes all the permissions from the “viewer” relation type, and adds the can-edit permission. The “viewer” relation type includes a single permission - can-view.

system:
  editor:
    union:
    - viewer
    permissions:
    - can-edit
  viewer:
    permissions:
    - can-view

If a user (or group) has the “editor” role, the Topaz check_permission built-in will return true when evaluating whether that user has the can-view permission. This is because the “editor” role transitively includes the “viewer” role, and therefore the can-view permission.

Fine-grained authorization for domain-specific objects

So far we’ve been dealing with “global” roles. Many applications want to grant permissions on a set of objects that they manage. For example, a file sharing application such as Google Drive defines “folders” and “files” as object types. Folders and files can both have a parent folder. Each of these objects has a set of relations (“owner,” “editor,” “commenter,” and “viewer”) and the “owner” can grant these roles to users and groups. So rather than a global “editor” role which has edit access to every file and folder, these permissions can be assigned to discrete folders and files.

Google’s Zanzibar authorization system, which powers Google Docs and many other Google applications, implements this model. Zanzibar has inspired many authorization systems, including Airbnb’s Himeji, Carta’s AuthZ, and a few open-source implementations, including Topaz.

With Topaz, you can define domain-specific object types and relation types. Each relation type can define permissions (and/or unions of other relation types). A full example of a manifest that supports this model can be found here.

Authorization models that are built purely in the form of evaluating relationships (e.g. “viewer,” “editor”) between subjects (users and groups) and objects (e.g. folders and files) can be expressed with very simple policies:

allowed {
  ds.check_permission({
    "subject": { "id": input.user.id },
    "permission": { "name": input.policy.path },
    "object": {
      "type": input.resource.type,
      "key": input.resource.key 
    }
  })
}

Combining group-based RBAC and FGA

Most real-world applications implement some combination of group-based RBAC and fine-grained authorization. Often, authorization includes checking a global role (e.g. “editor”) and then checking whether the user has access to a particular resource (e.g. a list). The user needs to satisfy both conditions to be able to edit items on that list.

Another example is a “super-admin,” who can do everything by virtue of having this role. Access checks include logic that allows users access to specific objects by virtue of relationships, as well as allowing access to users with these elevated roles.

Topaz supports these scenarios as well, since it is built on a combination of policy and relationship-based access control. To extend the previous example, we can add another “allowed” clause to this policy. This clause will allow the operation if the user has been granted a particular permission on a particular object, OR if they are a “super-admin”:


allowed {
  ds.check_permission({
    "subject": { "id": input.user.id },
    "permission": { "name": input.policy.path },
    "object": {
      "type": input.resource.type,
      "key": input.resource.key 
    }
  })
}

allowed {
  input.user.roles[_] == "super-admin"
}


Conclusion

We’ve covered five common authorization patterns, starting from the simplest IDP-based RBAC, and culminating in a combination of group-based RBAC with fine-grained permissions and fine-grained resources.

Topaz supports all of these models, and just as importantly, makes it easy to evolve from the simple models to the more sophisticated ones, by evolving the authorization policy.

Eventually, every successful application requires a deep set of authorization capabilities. Adopting an authorization platform like Topaz or Aserto early in your journey future-proofs your application, and makes it much easier to evolve your authorization model with your expanding requirements.

Omri Gazitt avatar

Omri Gazitt

CEO, Aserto