Modeling Permissions as Types in Typescript

One of the advantages of TypeScript is how its rich type system enables you to leverage the language to eliminate potential sources of error. On a few recent projects with nontrivial authorization needs, we’ve been using some simple techniques to do this with permissions checking and enforcement.

Scott Wlaschin documented the heart of the technique in his great blog, F# for Fun and Profit. Scott shows how you can create types that represent access tokens for particular permissions. Then the services that perform potentially unauthorized operations require a value of that type to perform the operation.

For example, let’s say our application has general system settings that only administrators can change. We might have a function in our service layer that looks something like this:


// Service layer / business logic layer
function updateSystemSettings(
  permission: PermissionToChangeSystemSettings,
  newSettings: SystemSettings
) {
  //....
}

By having a type represent permission to perform the operation and requiring a value of that type to perform the operation, we’ve got an API that’s impossible to accidentally call in a context where you haven’t proved you have that permission.

We’ve got a simple pattern and some lightweight support code for implementing this model in typescript — code and examples on GitHub. At a high level, we do the following:

  1. Define specific permissions as types using the Single-Valued Type pattern.
  2. Have routines in our service layer take values of the corresponding permission type as an argument.
  3. Define an “authorizer” that provides permission-checker functions that attempt to produce a value of the requested permission type and fail if the request isn’t authorized.
  4. API endpoints such as GraphQL resolvers or HTTP handlers request permissions from the authorizer and use them to invoke service layer business logic.

1. Define permissions.

Defining a basic permission is straightforward:


export const PermissionToChangeSystemSettings = Permission.declare(
  "ChangeSystemSettings"
);
export type PermissionToChangeSystemSettings = PermissionInstanceType< typeof PermissionToChangeSystemSettings >;

This does two things. First, it defines a simple permission to change system settings. In this example, the permission is all-or-nothing — you either have this permission or you do not. These simple or “unit” permissions just need a string identifier to capture which permission it is.

Two different things are created in this example — a constant and a type. The constant is a run-time representation of the general permission to change system settings in our Single-Valued Type  model. It’s used later when defining strategies for providing permissions. The type generated from it is the type of value you get when you actually use the permission.

Complex Permissions

Simple permissions, however, are often not granular enough. In real apps, you usually have permissions that vary more from user to user or entity to entity. For example, an app that allows users to create and view documents may want to ensure that only the owner can access them.

So, for more sophisticated cases, it’s useful to enable the permission to include runtime information indicating the precise scope of what’s been granted. For example, you might write a permission to “view a document” above as:


/** Grants permission to view a specific document, captured by the document ID. */
export const PermissionToViewDocument = Permission.declare(
  "PermissionToViewDocument",
  Permission.ofType<{ documentId: DocumentId }>()
);
export type PermissionToViewDocument = PermissionInstanceType< typeof PermissionToViewDocument >;

Declaration looks much the same as the PermissionToChangeSystemSettings above, but with the addition of Permission.ofType as a second argument to the declaration. This signature for Permission.declare allows you to specify an object payload type that can include any runtime data needed to understand the scope of the permission. That information will be present at runtime for any service layer method that’s consuming a permission.

2. Service layer routines accept permission values.

Once the types exist, you can start to write code that uses those permissions. As a general rule, any business logic that has authorization rules should take a reasonably specific permission representing that permission:


function getDocumentContent(
  permission: PermissionToViewDocument
): Promise {
  return loadDocumentContentFromDb(permission.documentId);
}

These functions take a permission value as an argument, and the only way to get a permission value is to prove you have that permission. That way, it becomes statically impossible to forget to check permissions in well-typed code.

One important principle we use with these permission types is to make the runtime data “load-bearing.” That means the runtime data encoded in the permission is used as the source of truth for the relevant target entities etc. In practice, this usually means taking entities or entity IDs from the permission itself instead of having those be passed in as separate arguments. This reduces the chance of inadvertently granting access to a different entity or behavior than what was granted. Additional arguments to these functions are totally fine — so long as they don’t relate to what thing has actually been authorized.

Need to unit test? You can use Permission.unsafeGrant to manufacture permissions for testing, or if you’re writing system code that by definition has access, e.g.

updateSystemSettings(Permission.unsafeGrant(PermissionToChangeSystemSettings), someNewValue);

const viewDocPerm = Permission.unsafeGrant(PermissionToViewDocument, { documentId: 32});
getDocumentContent(viewDocPerm)

3. Create a permission checker.

In principle, permission checkers can just be functions that return SomePermission | null. If you are allowed to perform the operation, you get a value of that type. If not, you don’t. In practice, we find it helpful to generate slightly richer APIs for checking permissions.

First, we create an Authorizer class that is constructed with user session info for convenience:


class Authorizer {
  constructor(private _user: UserSession) {}
  // ...
}

An instance of this is reachable from our dependency injection context for easy use (passed into all our GraphQL resolvers and express handlers).

Within the authorizer, we have a family of “permission” checkers, which look like:


  canChangeSystemSettings = Permission.checker(
    PermissionToChangeSystemSettings,
    () => {
      return this._user.role === "admin";
    }
  );

These checkers tie a specific permission to the necessary logic to prove that permission is allowed. For simple permissions, we can provide a synchronous or asynchronous predicate function. Permission.checker returns an object with three methods:


  /** Perform a simple permission check, returning null if permission is denied. */
  test(...args: TArgs): TPerm | null;

  /** Check for permission and return any data related to why permission was denied. */
  check(...args: TArgs): TPerm | Denial;

  /** Check for permission and throw a PermissionError if permission was denied */
  enforce(...args: TArgs): TPerm;

These methods provide convenient ways to check depending on the use case. For simple cases where you simply want to do a simple test for whether a request is authorized, you can use testtest returns the requested permission or null, and can be used to guide conditional logic.

For rest APIs and certain other use cases, it’s often nice to be able to fall back on a generic “access denied” path. enforce is useful for this purpose — it returns the required permission if allowed, or throws a PermissionError if not. It’s then easy to test for thrown PermissionError in an express middleware or other generic context to fall back on one-size-fits-all logic for common cases. For example, our rest APIs just respond with a 403 when PermissionError is uncaught.

check is like test, but gives you access to the message summarizing why permission was denied. It’s not as convenient as null checking, so we don’t use it often unless we’re aiming to do something like log info about why access was denied.

4. Check for permission before calling service layer.

Finally, tie it all together by testing for permissions in your API endpoints, background jobs, etc. to verify permission before calling into your authorized service layer logic.

For HTTP endpoints, we’ll often use enforce for a convenient check-and-call pattern, such as:


app.post('/settings', wrapRequest(async (req,res) => {
    await updateSystemSettings(
    auth.canChangeSystemSettings.enforce(),
    req.body
  );
  res.json({ status: 'ok' })
}))

Where wrapRequest might catch PermissionError and respond with a 403 among other things.


For more info check out the repo, especially the implementation) of the core types and helpers.