Skip to content
This repository has been archived by the owner on Apr 3, 2024. It is now read-only.

Authorization

Erik Roberts edited this page Mar 1, 2023 · 19 revisions

Overview

Authorization in the Glimpse API takes advantage of GraphQL directives to apply CASL rules. Directives allow you to perform actions before and after the resolver is called. Directives also allow you to transform the response before sending it to the client.

Glimpse's authorization flow is handled by the files located in the /src/casl directory.

  • casl.module.ts - The CASL module which can be imported into other NestJS modules, allowing you to use CASL services within those modules.
  • casl-ability.factory.ts - A NestJS Provider that allows you to generate CASL abilities for a given user. This is done immediately for you within the CASL interceptor.
  • casl.interceptor.ts - An NestJS interceptor that sets up the user's CASL permissions before processing an HTTP request.
  • casl.plugin.ts - An Apollo plugin that sets up the user's CASL permissions before processing a GraphQL request.
  • casl.helper.ts - A NestJS Provider with a number of helper functions for CASL rules checks. Primarily, this class includes methods to test CASL abilities against Rules, and is used within the CASL interceptor.
  • rules.decorator.ts - Deprecated, HTTP-only. See dropdown below.
  • rule.directive.ts - A class containing definitions for the GraphQL @rule and @custom_rule directives. These directives can be applied to resolvers to easily define required permissions for a given resource.

Authorization for non-GraphQL contexts (e.g. HTTP) is currently not supported. If you believe that you need this functionality for future features of the API, please read the dropdown below. Otherwise, if you only care about authorization in GraphQL contexts, then carry on!

Authorization in HTTP contexts

When originally building the authorization system for the Glimpse API, everything was built on NestJS interceptors which, similarly to directives, allow you to perform logic before and after requests. NestJS is designed with RESTful applications in mind first, but still treats GraphQL as a first-class citizen. To my delight, NestJS interceptors are purported to work perfectly within GraphQL contexts as well as the typical HTTP contexts. Instead of just intercepting the whole initial HTTP GraphQL request, interceptors appeared to be capable of intercepting at the resolver-level. This would have been perfect for our application, as it would allow us to have a single @Rule interceptor within our code that can be applied to either GraphQL resolvers or classical HTTP endpoints, and the blackbox behavior would remain the same.

This worked well for a while, but unfortunately once it came time to implement and test nested relational resolvers, I quickly realized that NestJS interceptors weren't going to cut it. NestJS interceptors can only be applied to top-level resolvers within the built-in Query, Mutation, and Subscription types. Bummer!

The backup plan was to ultimately split the way in which rules are applied into two systems: GraphQL directives for GraphQL contexts, and continue to use NestJS interceptors for HTTP contexts. Under the hood, both systems would still call the same functions to handle permission checks. This works, however a few minor shortcuts were taken to get the old interceptor-based system working seamlessly with the new directive-based system.

At this point in the life of the API, there is no need for authorization within HTTP contexts. It was going to be there and ready to go with the interceptors in case it was needed, however with the departure from the interceptors system for GraphQL contexts, it did not make sense to spend time polishing and testing a feature which we may never use. For this reason, while much of the code is still there for the NestJS interceptors, it is considered deprecated, and not recommended you use it without a test suite, or at least thoroughly manually testing it.

The NestJS interceptor can be applied via the @Rule decorator (/src/casl/rule.decorator.ts) and the logic for detecting and applying those rules is handled within the CASL interceptor (/src/casl/casl.interceptor.ts). Again, these features should be considered deprecated. If a valid use-case for them does not come up in the near future, they should be considered for deletion, and brought back and updated further down the road if ever necessary.


Authorization Steps

When a user requests a protected resource, we want to verify that the user can or cannot access the resource they're requesting in as few steps as possible. Evaluating CASL rules is much cheaper than querying the database, so we evaluate a user's permissions often. In general, there are five types of CRUD queries for a given resource type:

  • Read one
  • Read many
  • Create
  • Update
  • Delete

ℹ️ For most of the remainder of this article, we're going to talk about performing these actions against a "Production" type resource, however the same concepts apply to all types of resources. Before reading further, it may be helpful to read the article on CASL authorization.

Read One

Reading a single record from the database is the simplest action when it comes to authorizing a user.

  1. Check the user has permission to read at least one field on at least one Production.
  2. Check the user has permission to read all the requested fields on at least one Production.
  3. Get the Production from the database (done by the resolver).
  4. Check user has permission to read at least one field on this specific Production.
  5. Check the user has permission to read all the requested fields on this specific Production.

Step 2 requires knowledge of which fields the user has requested. This is only possible in a GraphQL context, since the requested fields are supplied in the request body. For HTTP requests, step 2 is skipped. Step 5 will catch any authorization issues that step 2 would have otherwise caught, however as we stated before, database queries are expensive, and we want to avoid them.

By the same token, "requested fields" in an HTTP context is going to be all of the fields within the object returned from the method handler in step 3. Unlike GraphQL, specific fields cannot be requested, and therefore all fields returned by the method handler will subsequently be returned to the user if their authorization checks pass.

Read Many

Reading multiple records in a single query is more complex than a single record. Not only do we now have to check each record within a returned array, but we also need to verify the user has permission to use any provided filter and sort inputs.

  1. Check the user has permission to read at least one field on at least one Production.
  2. Check the user has permission to read all the requested fields on at least one Production.
  3. Check the user has permission to read the fields used to filter the response for at least one Production.
  4. Check the user has permission to unconditionally read the fields used to order the Productions.
  5. Get the Productions from the database (done by the resolver).
  6. Check user has permission to read at least one field on each Production.
  7. Check the user has permission to read all the requested fields on each Production.
  8. Check the user has permission to read the filtered values on each Production.
  9. Check the user has permission to read the ordering values on each Production.

Filtering

In this case, steps 1 and 2 are exactly the same as steps 1 and 2 from the singular read case. After that, though, things change. Now that the user is able to filter the Productions included in the response, not only do they need permission to read the fields they actually requested, but they also need permission to read the fields which they are filtering by, even if those fields are not included in the actual response.

As an example, if the user wants to get all Productions which are live, they may provide a filter object such as { "live": { "equals": true } }. However, in a GraphQL context, they may not request the "live" field to be included in the response. In fact, they probably won't: they can infer that for all the returned Productions, the "live" value would be true based on their filter, so they don't need to explicitly request it. In an HTTP context, this may not make as much sense, since the "live" value will likely be included in the response anyway. However, this is ultimately up to the method handler's implementation, so we must perform the check regardless.

Step 3 is responsible for checking that the user is authorized to read the field(s) used to filter the response on at least one Production. You may think that it'd also make sense to make sure that the user can also read the field values used in the filter, however Glimpse's filtering system is a subset of Prisma's filtering system, which allows the user to not just filter based on equality, but also mathematical comparisons (<, >, <=, >=) and substring searching. We cannot easily check these permissions without a literal value, so it turns out in this case it is usually better to perform the value checks after the resolver/method handler has returned values (step 8).

Sorting

For the same reasons as we listed above, when the user applies a filter, the user must also have permission to read the fields which they use to sort by. Sorting introduces an additional problem we didn't have with filtering, though; particularly when used in combination with pagination. Imagine you have three records in your database:

[
  {"name": "Production 1", "secret": 7, "public": true},
  {"name": "Production 2", "secret": 8, "public": false},
  {"name": "Production 3", "secret": 9, "public": true}
]

And imagine the user has permission to read "name" on all Productions, but only "secret" on Productions which are public. If the user requests the field "name" and orders "secret" in descending order, this would normally throw a Forbidden error in step 9, when it's realized that the user doesn't have permission to read "secret" on Production 2. However, if the user uses pagination to request only one document at a time, they would only get a Forbidden error on the second page (i.e., when requesting Production 2). From this, they can infer that Production 2 must have a secret value between 7 and 9. If secret values are unique integers, then they are able to conclusively infer that Production 2 has a secret value of 8. To solve this issue, the user is not allowed to order by fields which they have any conditional permissions against.

Create

WIP

  • Check the user has permission to create at least one field on objects of the given type
  • Check the user has permission to create all the supplied fields on objects of the given type
  • Tentatively create the object via a transaction
  • Check the user has permission to create at least one field on the object
  • Check the user has permission to create all the fields on the object, including defaults
  • Save the transaction, or rollback if either of the previous two checks failed.

Update

WIP

  • Check the user has permission to update at least one field on objects of the given type
  • Check the user has permission to update all the supplied fields on objects of the given type
  • Get the object to be updated
  • Check the user has permission to update at least one field on the object
  • Check the user has permission to update all the fields on the object
  • Tentatively update the object via a transaction
  • Check the user has permission to update at least one field on the object
  • Check the user has permission to update all the fields on the object
  • Save the transaction, or rollback if either of the previous two checks failed.

Delete

WIP

  • Check the user has permission to delete objects of the given type*
  • Get the object to be deleted
  • Check the user has permission to delete the object
  • Delete the object

*NOTE: Field-based permissions do not make sense in the context of record deletion. There should be constraints (either in code or the database itself) to specifically prevent field restrictions from being set on delete rules.